jenkins-bot has submitted this change and it was merged.

Change subject: Stats: Weekly trends
......................................................................


Stats: Weekly trends

While doing this, as per Pau's suggestion, I changed
the colors (again).

Also upgraded Chart.js and added Chart.Bar.js for
bar charts.

Bug: T105192
Change-Id: I8301bcd7e595e8144a43622a27503b8ed9494bbf
---
M extension.json
M i18n/en.json
M i18n/qqq.json
A lib/chart.js/Chart.Bar.js
M lib/chart.js/Chart.Core.js
M lib/chart.js/Chart.Line.js
M modules/stats/ext.cx.stats.js
M modules/stats/styles/ext.cx.stats.less
8 files changed, 712 insertions(+), 63 deletions(-)

Approvals:
  Nikerabbit: Looks good to me, approved
  jenkins-bot: Verified



diff --git a/extension.json b/extension.json
index 5ed4bb8..383b0d0 100644
--- a/extension.json
+++ b/extension.json
@@ -867,6 +867,8 @@
                                "cx-stats-local-published-number",
                                "cx-stats-local-published",
                                "cx-stats-grouping-title",
+                               "cx-trend-published-translations-title",
+                               "cx-trend-translations-to-title",
                                "percent"
                        ]
                },
@@ -875,7 +877,8 @@
                        "remoteExtPath": "ContentTranslation/lib",
                        "scripts": [
                                "chart.js/Chart.Core.js",
-                               "chart.js/Chart.Line.js"
+                               "chart.js/Chart.Line.js",
+                               "chart.js/Chart.Bar.js"
                        ]
                },
                "ext.cx.beta.notification": {
diff --git a/i18n/en.json b/i18n/en.json
index bf13237..159737a 100644
--- a/i18n/en.json
+++ b/i18n/en.json
@@ -169,6 +169,8 @@
        "cx-stats-local-published-number": "$1 in $2",
        "cx-stats-local-published": "$1 ($3) in $2",
        "cx-stats-grouping-title": "{{PLURAL:$1|$1 translation|$1 
translations}}",
+       "cx-trend-published-translations-title": "Translation trend",
+       "cx-trend-translations-to-title": "Translation trend to $1",
        "cx-tools-missing-link-text": "{{GENDER:|Mark}} the page as missing to 
encourage its creation.",
        "cx-tools-missing-link-tooltip": "Translate (in new window)",
        "cx-tools-missing-link-title": "Missing link",
diff --git a/i18n/qqq.json b/i18n/qqq.json
index 45c4b4b..7f2cae3 100644
--- a/i18n/qqq.json
+++ b/i18n/qqq.json
@@ -174,6 +174,8 @@
        "cx-stats-local-published-number": "A message shown in 
[[Special:CXStats]] as highlights of CX analytics. $1 is a number, $2 is 
language name (autonym).",
        "cx-stats-local-published": "A message shown in [[Special:CXStats]] as 
highlights of CX analytics. $1 is a number, $2 is language name (autonym), $3 
is percentage.",
        "cx-stats-grouping-title": "Title text for language grouping based on 
number of translations. $1 is number of 
translations\n{{Identical|Translation}}",
+       "cx-trend-published-translations-title": "A message shown in 
[[Special:CXStats]]",
+       "cx-trend-translations-to-title": "Label shown in the legend section 
Content translation trends graph visualization.\n* $1 - language name",
        "cx-tools-missing-link-text": "Message with instructions for marking 
links as missing",
        "cx-tools-missing-link-tooltip": "Tooltip that shows when hovering over 
target link card link when working with missing links.\nClicking on link opens 
a new translation view for the missing link.",
        "cx-tools-missing-link-title": "Title for target link card when card is 
used for working with missing links",
diff --git a/lib/chart.js/Chart.Bar.js b/lib/chart.js/Chart.Bar.js
new file mode 100644
index 0000000..81532b4
--- /dev/null
+++ b/lib/chart.js/Chart.Bar.js
@@ -0,0 +1,303 @@
+(function(){
+       "use strict";
+
+       var root = this,
+               Chart = root.Chart,
+               helpers = Chart.helpers;
+
+
+       var defaultConfig = {
+               //Boolean - Whether the scale should start at zero, or an order 
of magnitude down from the lowest value
+               scaleBeginAtZero : true,
+
+               //Boolean - Whether grid lines are shown across the chart
+               scaleShowGridLines : true,
+
+               //String - Colour of the grid lines
+               scaleGridLineColor : "rgba(0,0,0,.05)",
+
+               //Number - Width of the grid lines
+               scaleGridLineWidth : 1,
+
+               //Boolean - Whether to show horizontal lines (except X axis)
+               scaleShowHorizontalLines: true,
+
+               //Boolean - Whether to show vertical lines (except Y axis)
+               scaleShowVerticalLines: true,
+
+               //Boolean - If there is a stroke on each bar
+               barShowStroke : true,
+
+               //Number - Pixel width of the bar stroke
+               barStrokeWidth : 2,
+
+               //Number - Spacing between each of the X value sets
+               barValueSpacing : 5,
+
+               //Number - Spacing between data sets within X values
+               barDatasetSpacing : 1,
+
+               //String - A legend template
+               legendTemplate : "<ul 
class=\"<%=name.toLowerCase()%>-legend\"><% for (var i=0; i<datasets.length; 
i++){%><li><span 
style=\"background-color:<%=datasets[i].fillColor%>\"><%if(datasets[i].label){%><%=datasets[i].label%><%}%></span></li><%}%></ul>"
+
+       };
+
+
+       Chart.Type.extend({
+               name: "Bar",
+               defaults : defaultConfig,
+               initialize:  function(data){
+
+                       //Expose options as a scope variable here so we can 
access it in the ScaleClass
+                       var options = this.options;
+
+                       this.ScaleClass = Chart.Scale.extend({
+                               offsetGridLines : true,
+                               calculateBarX : function(datasetCount, 
datasetIndex, barIndex){
+                                       //Reusable method for calculating the 
xPosition of a given bar based on datasetIndex & width of the bar
+                                       var xWidth = this.calculateBaseWidth(),
+                                               xAbsolute = 
this.calculateX(barIndex) - (xWidth/2),
+                                               barWidth = 
this.calculateBarWidth(datasetCount);
+
+                                       return xAbsolute + (barWidth * 
datasetIndex) + (datasetIndex * options.barDatasetSpacing) + barWidth/2;
+                               },
+                               calculateBaseWidth : function(){
+                                       return (this.calculateX(1) - 
this.calculateX(0)) - (2*options.barValueSpacing);
+                               },
+                               calculateBarWidth : function(datasetCount){
+                                       //The padding between datasets is to 
the right of each bar, providing that there are more than 1 dataset
+                                       var baseWidth = 
this.calculateBaseWidth() - ((datasetCount - 1) * options.barDatasetSpacing);
+
+                                       return (baseWidth / datasetCount);
+                               }
+                       });
+
+                       this.datasets = [];
+
+                       //Set up tooltip events on the chart
+                       if (this.options.showTooltips){
+                               helpers.bindEvents(this, 
this.options.tooltipEvents, function(evt){
+                                       var activeBars = (evt.type !== 
'mouseout') ? this.getBarsAtEvent(evt) : [];
+
+                                       this.eachBars(function(bar){
+                                               bar.restore(['fillColor', 
'strokeColor']);
+                                       });
+                                       helpers.each(activeBars, 
function(activeBar){
+                                               activeBar.fillColor = 
activeBar.highlightFill;
+                                               activeBar.strokeColor = 
activeBar.highlightStroke;
+                                       });
+                                       this.showTooltip(activeBars);
+                               });
+                       }
+
+                       //Declare the extension of the default point, to cater 
for the options passed in to the constructor
+                       this.BarClass = Chart.Rectangle.extend({
+                               strokeWidth : this.options.barStrokeWidth,
+                               showStroke : this.options.barShowStroke,
+                               ctx : this.chart.ctx
+                       });
+
+                       //Iterate through each of the datasets, and build this 
into a property of the chart
+                       
helpers.each(data.datasets,function(dataset,datasetIndex){
+
+                               var datasetObject = {
+                                       label : dataset.label || null,
+                                       fillColor : dataset.fillColor,
+                                       strokeColor : dataset.strokeColor,
+                                       bars : []
+                               };
+
+                               this.datasets.push(datasetObject);
+
+                               
helpers.each(dataset.data,function(dataPoint,index){
+                                       //Add a new point for each piece of 
data, passing any required data to draw.
+                                       datasetObject.bars.push(new 
this.BarClass({
+                                               value : dataPoint,
+                                               label : data.labels[index],
+                                               datasetLabel: dataset.label,
+                                               strokeColor : 
dataset.strokeColor,
+                                               fillColor : dataset.fillColor,
+                                               highlightFill : 
dataset.highlightFill || dataset.fillColor,
+                                               highlightStroke : 
dataset.highlightStroke || dataset.strokeColor
+                                       }));
+                               },this);
+
+                       },this);
+
+                       this.buildScale(data.labels);
+
+                       this.BarClass.prototype.base = this.scale.endPoint;
+
+                       this.eachBars(function(bar, index, datasetIndex){
+                               helpers.extend(bar, {
+                                       width : 
this.scale.calculateBarWidth(this.datasets.length),
+                                       x: 
this.scale.calculateBarX(this.datasets.length, datasetIndex, index),
+                                       y: this.scale.endPoint
+                               });
+                               bar.save();
+                       }, this);
+
+                       this.render();
+               },
+               update : function(){
+                       this.scale.update();
+                       // Reset any highlight colours before updating.
+                       helpers.each(this.activeElements, 
function(activeElement){
+                               activeElement.restore(['fillColor', 
'strokeColor']);
+                       });
+
+                       this.eachBars(function(bar){
+                               bar.save();
+                       });
+                       this.render();
+               },
+               eachBars : function(callback){
+                       helpers.each(this.datasets,function(dataset, 
datasetIndex){
+                               helpers.each(dataset.bars, callback, this, 
datasetIndex);
+                       },this);
+               },
+               getBarsAtEvent : function(e){
+                       var barsArray = [],
+                               eventPosition = helpers.getRelativePosition(e),
+                               datasetIterator = function(dataset){
+                                       barsArray.push(dataset.bars[barIndex]);
+                               },
+                               barIndex;
+
+                       for (var datasetIndex = 0; datasetIndex < 
this.datasets.length; datasetIndex++) {
+                               for (barIndex = 0; barIndex < 
this.datasets[datasetIndex].bars.length; barIndex++) {
+                                       if 
(this.datasets[datasetIndex].bars[barIndex].inRange(eventPosition.x,eventPosition.y)){
+                                               helpers.each(this.datasets, 
datasetIterator);
+                                               return barsArray;
+                                       }
+                               }
+                       }
+
+                       return barsArray;
+               },
+               buildScale : function(labels){
+                       var self = this;
+
+                       var dataTotal = function(){
+                               var values = [];
+                               self.eachBars(function(bar){
+                                       values.push(bar.value);
+                               });
+                               return values;
+                       };
+
+                       var scaleOptions = {
+                               templateString : this.options.scaleLabel,
+                               height : this.chart.height,
+                               width : this.chart.width,
+                               ctx : this.chart.ctx,
+                               textColor : this.options.scaleFontColor,
+                               fontSize : this.options.scaleFontSize,
+                               fontStyle : this.options.scaleFontStyle,
+                               fontFamily : this.options.scaleFontFamily,
+                               valuesCount : labels.length,
+                               beginAtZero : this.options.scaleBeginAtZero,
+                               integersOnly : this.options.scaleIntegersOnly,
+                               calculateYRange: function(currentHeight){
+                                       var updatedRanges = 
helpers.calculateScaleRange(
+                                               dataTotal(),
+                                               currentHeight,
+                                               this.fontSize,
+                                               this.beginAtZero,
+                                               this.integersOnly
+                                       );
+                                       helpers.extend(this, updatedRanges);
+                               },
+                               xLabels : labels,
+                               font : 
helpers.fontString(this.options.scaleFontSize, this.options.scaleFontStyle, 
this.options.scaleFontFamily),
+                               lineWidth : this.options.scaleLineWidth,
+                               lineColor : this.options.scaleLineColor,
+                               showHorizontalLines : 
this.options.scaleShowHorizontalLines,
+                               showVerticalLines : 
this.options.scaleShowVerticalLines,
+                               gridLineWidth : 
(this.options.scaleShowGridLines) ? this.options.scaleGridLineWidth : 0,
+                               gridLineColor : 
(this.options.scaleShowGridLines) ? this.options.scaleGridLineColor : 
"rgba(0,0,0,0)",
+                               padding : (this.options.showScale) ? 0 : 
(this.options.barShowStroke) ? this.options.barStrokeWidth : 0,
+                               showLabels : this.options.scaleShowLabels,
+                               display : this.options.showScale
+                       };
+
+                       if (this.options.scaleOverride){
+                               helpers.extend(scaleOptions, {
+                                       calculateYRange: helpers.noop,
+                                       steps: this.options.scaleSteps,
+                                       stepValue: this.options.scaleStepWidth,
+                                       min: this.options.scaleStartValue,
+                                       max: this.options.scaleStartValue + 
(this.options.scaleSteps * this.options.scaleStepWidth)
+                               });
+                       }
+
+                       this.scale = new this.ScaleClass(scaleOptions);
+               },
+               addData : function(valuesArray,label){
+                       //Map the values array for each of the datasets
+                       helpers.each(valuesArray,function(value,datasetIndex){
+                               //Add a new point for each piece of data, 
passing any required data to draw.
+                               this.datasets[datasetIndex].bars.push(new 
this.BarClass({
+                                       value : value,
+                                       label : label,
+                                       datasetLabel: 
this.datasets[datasetIndex].label,
+                                       x: 
this.scale.calculateBarX(this.datasets.length, datasetIndex, 
this.scale.valuesCount+1),
+                                       y: this.scale.endPoint,
+                                       width : 
this.scale.calculateBarWidth(this.datasets.length),
+                                       base : this.scale.endPoint,
+                                       strokeColor : 
this.datasets[datasetIndex].strokeColor,
+                                       fillColor : 
this.datasets[datasetIndex].fillColor
+                               }));
+                       },this);
+
+                       this.scale.addXLabel(label);
+                       //Then re-render the chart.
+                       this.update();
+               },
+               removeData : function(){
+                       this.scale.removeXLabel();
+                       //Then re-render the chart.
+                       helpers.each(this.datasets,function(dataset){
+                               dataset.bars.shift();
+                       },this);
+                       this.update();
+               },
+               reflow : function(){
+                       helpers.extend(this.BarClass.prototype,{
+                               y: this.scale.endPoint,
+                               base : this.scale.endPoint
+                       });
+                       var newScaleProps = helpers.extend({
+                               height : this.chart.height,
+                               width : this.chart.width
+                       });
+                       this.scale.update(newScaleProps);
+               },
+               draw : function(ease){
+                       var easingDecimal = ease || 1;
+                       this.clear();
+
+                       var ctx = this.chart.ctx;
+
+                       this.scale.draw(easingDecimal);
+
+                       //Draw all the bars for each dataset
+                       
helpers.each(this.datasets,function(dataset,datasetIndex){
+                               helpers.each(dataset.bars,function(bar,index){
+                                       if (bar.hasValue()){
+                                               bar.base = this.scale.endPoint;
+                                               //Transition then draw
+                                               bar.transition({
+                                                       x : 
this.scale.calculateBarX(this.datasets.length, datasetIndex, index),
+                                                       y : 
this.scale.calculateY(bar.value),
+                                                       width : 
this.scale.calculateBarWidth(this.datasets.length)
+                                               }, easingDecimal).draw();
+                                       }
+                               },this);
+
+                       },this);
+               }
+       });
+
+
+}).call(this);
diff --git a/lib/chart.js/Chart.Core.js b/lib/chart.js/Chart.Core.js
old mode 100755
new mode 100644
index 5dccd2e..aa6c58d
--- a/lib/chart.js/Chart.Core.js
+++ b/lib/chart.js/Chart.Core.js
@@ -35,17 +35,17 @@
                        {
                                return 
document.defaultView.getComputedStyle(element).getPropertyValue(dimension);
                        }
-               }
+               };
 
-               var width = this.width = 
computeDimension(context.canvas,'Width');
-               var height = this.height = 
computeDimension(context.canvas,'Height');
+               var width = this.width = 
computeDimension(context.canvas,'Width') || context.canvas.width;
+               var height = this.height = 
computeDimension(context.canvas,'Height') || context.canvas.height;
 
                // Firefox requires this to work correctly
                context.canvas.width  = width;
                context.canvas.height = height;
 
-               var width = this.width = context.canvas.width;
-               var height = this.height = context.canvas.height;
+               width = this.width = context.canvas.width;
+               height = this.height = context.canvas.height;
                this.aspectRatio = this.width / this.height;
                //High pixel density displays - multiply the size of the canvas 
height/width by the device pixel ratio, then scale.
                helpers.retinaScale(this);
@@ -150,6 +150,9 @@
                        // String - Tooltip title font colour
                        tooltipTitleFontColor: "#fff",
 
+                       // String - Tooltip title template
+                       tooltipTitleTemplate: "<%= label%>",
+
                        // Number - pixel width of padding around tooltip text
                        tooltipYPadding: 6,
 
@@ -210,14 +213,18 @@
                clone = helpers.clone = function(obj){
                        var objClone = {};
                        each(obj,function(value,key){
-                               if (obj.hasOwnProperty(key)) objClone[key] = 
value;
+                               if (obj.hasOwnProperty(key)){
+                                       objClone[key] = value;
+                               }
                        });
                        return objClone;
                },
                extend = helpers.extend = function(base){
                        each(Array.prototype.slice.call(arguments,1), 
function(extensionObject) {
                                each(extensionObject,function(value,key){
-                                       if 
(extensionObject.hasOwnProperty(key)) base[key] = value;
+                                       if 
(extensionObject.hasOwnProperty(key)){
+                                               base[key] = value;
+                                       }
                                });
                        });
                        return base;
@@ -300,9 +307,9 @@
                })(),
                warn = helpers.warn = function(str){
                        //Method for warning of errors
-                       if (window.console && typeof window.console.warn == 
"function") console.warn(str);
+                       if (window.console && typeof window.console.warn === 
"function") console.warn(str);
                },
-               amd = helpers.amd = (typeof define == 'function' && define.amd),
+               amd = helpers.amd = (typeof define === 'function' && 
define.amd),
                //-- Math methods
                isNumber = helpers.isNumber = function(n){
                        return !isNaN(parseFloat(n)) && isFinite(n);
@@ -328,7 +335,20 @@
                },
                getDecimalPlaces = helpers.getDecimalPlaces = function(num){
                        if (num%1!==0 && isNumber(num)){
-                               return num.toString().split(".")[1].length;
+                               var s = num.toString();
+                               if(s.indexOf("e-") < 0){
+                                       // no exponent, e.g. 0.01
+                                       return s.split(".")[1].length;
+                               }
+                               else if(s.indexOf(".") < 0) {
+                                       // no decimal point, e.g. 1e-9
+                                       return parseInt(s.split("e-")[1]);
+                               }
+                               else {
+                                       // exponent and decimal point, e.g. 
1.23e-9
+                                       var parts = s.split(".")[1].split("e-");
+                                       return parts[0].length + 
parseInt(parts[1]);
+                               }
                        }
                        else {
                                return 0;
@@ -390,7 +410,7 @@
                        var maxValue = max(valuesArray),
                                minValue = min(valuesArray);
 
-                       // We need some degree of seperation here to calculate 
the scales if all the values are the same
+                       // We need some degree of separation here to calculate 
the scales if all the values are the same
                        // Adding/minusing 0.5 will give us a range of 1.
                        if (maxValue === minValue){
                                maxValue += 0.5;
@@ -505,7 +525,7 @@
                /* jshint ignore:end */
                generateLabels = helpers.generateLabels = 
function(templateString,numberOfSteps,graphMin,stepValue){
                        var labelsArray = new Array(numberOfSteps);
-                       if (labelTemplateString){
+                       if (templateString){
                                each(labelsArray,function(val,index){
                                        labelsArray[index] = 
template(templateString,{value: (graphMin + (stepValue*(index+1)))});
                                });
@@ -526,7 +546,9 @@
                                return -1 * t * (t - 2);
                        },
                        easeInOutQuad: function (t) {
-                               if ((t /= 1 / 2) < 1) return 1 / 2 * t * t;
+                               if ((t /= 1 / 2) < 1){
+                                       return 1 / 2 * t * t;
+                               }
                                return -1 / 2 * ((--t) * (t - 2) - 1);
                        },
                        easeInCubic: function (t) {
@@ -536,7 +558,9 @@
                                return 1 * ((t = t / 1 - 1) * t * t + 1);
                        },
                        easeInOutCubic: function (t) {
-                               if ((t /= 1 / 2) < 1) return 1 / 2 * t * t * t;
+                               if ((t /= 1 / 2) < 1){
+                                       return 1 / 2 * t * t * t;
+                               }
                                return 1 / 2 * ((t -= 2) * t * t + 2);
                        },
                        easeInQuart: function (t) {
@@ -546,7 +570,9 @@
                                return -1 * ((t = t / 1 - 1) * t * t * t - 1);
                        },
                        easeInOutQuart: function (t) {
-                               if ((t /= 1 / 2) < 1) return 1 / 2 * t * t * t 
* t;
+                               if ((t /= 1 / 2) < 1){
+                                       return 1 / 2 * t * t * t * t;
+                               }
                                return -1 / 2 * ((t -= 2) * t * t * t - 2);
                        },
                        easeInQuint: function (t) {
@@ -556,7 +582,9 @@
                                return 1 * ((t = t / 1 - 1) * t * t * t * t + 
1);
                        },
                        easeInOutQuint: function (t) {
-                               if ((t /= 1 / 2) < 1) return 1 / 2 * t * t * t 
* t * t;
+                               if ((t /= 1 / 2) < 1){
+                                       return 1 / 2 * t * t * t * t * t;
+                               }
                                return 1 / 2 * ((t -= 2) * t * t * t * t + 2);
                        },
                        easeInSine: function (t) {
@@ -575,60 +603,95 @@
                                return (t === 1) ? 1 : 1 * (-Math.pow(2, -10 * 
t / 1) + 1);
                        },
                        easeInOutExpo: function (t) {
-                               if (t === 0) return 0;
-                               if (t === 1) return 1;
-                               if ((t /= 1 / 2) < 1) return 1 / 2 * 
Math.pow(2, 10 * (t - 1));
+                               if (t === 0){
+                                       return 0;
+                               }
+                               if (t === 1){
+                                       return 1;
+                               }
+                               if ((t /= 1 / 2) < 1){
+                                       return 1 / 2 * Math.pow(2, 10 * (t - 
1));
+                               }
                                return 1 / 2 * (-Math.pow(2, -10 * --t) + 2);
                        },
                        easeInCirc: function (t) {
-                               if (t >= 1) return t;
+                               if (t >= 1){
+                                       return t;
+                               }
                                return -1 * (Math.sqrt(1 - (t /= 1) * t) - 1);
                        },
                        easeOutCirc: function (t) {
                                return 1 * Math.sqrt(1 - (t = t / 1 - 1) * t);
                        },
                        easeInOutCirc: function (t) {
-                               if ((t /= 1 / 2) < 1) return -1 / 2 * 
(Math.sqrt(1 - t * t) - 1);
+                               if ((t /= 1 / 2) < 1){
+                                       return -1 / 2 * (Math.sqrt(1 - t * t) - 
1);
+                               }
                                return 1 / 2 * (Math.sqrt(1 - (t -= 2) * t) + 
1);
                        },
                        easeInElastic: function (t) {
                                var s = 1.70158;
                                var p = 0;
                                var a = 1;
-                               if (t === 0) return 0;
-                               if ((t /= 1) == 1) return 1;
-                               if (!p) p = 1 * 0.3;
+                               if (t === 0){
+                                       return 0;
+                               }
+                               if ((t /= 1) == 1){
+                                       return 1;
+                               }
+                               if (!p){
+                                       p = 1 * 0.3;
+                               }
                                if (a < Math.abs(1)) {
                                        a = 1;
                                        s = p / 4;
-                               } else s = p / (2 * Math.PI) * Math.asin(1 / a);
+                               } else{
+                                       s = p / (2 * Math.PI) * Math.asin(1 / 
a);
+                               }
                                return -(a * Math.pow(2, 10 * (t -= 1)) * 
Math.sin((t * 1 - s) * (2 * Math.PI) / p));
                        },
                        easeOutElastic: function (t) {
                                var s = 1.70158;
                                var p = 0;
                                var a = 1;
-                               if (t === 0) return 0;
-                               if ((t /= 1) == 1) return 1;
-                               if (!p) p = 1 * 0.3;
+                               if (t === 0){
+                                       return 0;
+                               }
+                               if ((t /= 1) == 1){
+                                       return 1;
+                               }
+                               if (!p){
+                                       p = 1 * 0.3;
+                               }
                                if (a < Math.abs(1)) {
                                        a = 1;
                                        s = p / 4;
-                               } else s = p / (2 * Math.PI) * Math.asin(1 / a);
+                               } else{
+                                       s = p / (2 * Math.PI) * Math.asin(1 / 
a);
+                               }
                                return a * Math.pow(2, -10 * t) * Math.sin((t * 
1 - s) * (2 * Math.PI) / p) + 1;
                        },
                        easeInOutElastic: function (t) {
                                var s = 1.70158;
                                var p = 0;
                                var a = 1;
-                               if (t === 0) return 0;
-                               if ((t /= 1 / 2) == 2) return 1;
-                               if (!p) p = 1 * (0.3 * 1.5);
+                               if (t === 0){
+                                       return 0;
+                               }
+                               if ((t /= 1 / 2) == 2){
+                                       return 1;
+                               }
+                               if (!p){
+                                       p = 1 * (0.3 * 1.5);
+                               }
                                if (a < Math.abs(1)) {
                                        a = 1;
                                        s = p / 4;
-                               } else s = p / (2 * Math.PI) * Math.asin(1 / a);
-                               if (t < 1) return -0.5 * (a * Math.pow(2, 10 * 
(t -= 1)) * Math.sin((t * 1 - s) * (2 * Math.PI) / p));
+                               } else {
+                                       s = p / (2 * Math.PI) * Math.asin(1 / 
a);
+                               }
+                               if (t < 1){
+                                       return -0.5 * (a * Math.pow(2, 10 * (t 
-= 1)) * Math.sin((t * 1 - s) * (2 * Math.PI) / p));}
                                return a * Math.pow(2, -10 * (t -= 1)) * 
Math.sin((t * 1 - s) * (2 * Math.PI) / p) * 0.5 + 1;
                        },
                        easeInBack: function (t) {
@@ -641,7 +704,9 @@
                        },
                        easeInOutBack: function (t) {
                                var s = 1.70158;
-                               if ((t /= 1 / 2) < 1) return 1 / 2 * (t * t * 
(((s *= (1.525)) + 1) * t - s));
+                               if ((t /= 1 / 2) < 1){
+                                       return 1 / 2 * (t * t * (((s *= 
(1.525)) + 1) * t - s));
+                               }
                                return 1 / 2 * ((t -= 2) * t * (((s *= (1.525)) 
+ 1) * t + s) + 2);
                        },
                        easeInBounce: function (t) {
@@ -659,7 +724,9 @@
                                }
                        },
                        easeInOutBounce: function (t) {
-                               if (t < 1 / 2) return 
easingEffects.easeInBounce(t * 2) * 0.5;
+                               if (t < 1 / 2){
+                                       return easingEffects.easeInBounce(t * 
2) * 0.5;
+                               }
                                return easingEffects.easeOutBounce(t * 2 - 1) * 
0.5 + 1 * 0.5;
                        }
                },
@@ -762,14 +829,21 @@
                        });
                },
                getMaximumWidth = helpers.getMaximumWidth = function(domNode){
-                       var container = domNode.parentNode;
+                       var container = domNode.parentNode,
+                           padding = parseInt(getStyle(container, 
'padding-left')) + parseInt(getStyle(container, 'padding-right'));
                        // TODO = check cross browser stuff with this.
-                       return container.clientWidth;
+                       return container.clientWidth - padding;
                },
                getMaximumHeight = helpers.getMaximumHeight = function(domNode){
-                       var container = domNode.parentNode;
+                       var container = domNode.parentNode,
+                           padding = parseInt(getStyle(container, 
'padding-bottom')) + parseInt(getStyle(container, 'padding-top'));
                        // TODO = check cross browser stuff with this.
-                       return container.clientHeight;
+                       return container.clientHeight - padding;
+               },
+               getStyle = helpers.getStyle = function (el, property) {
+                       return el.currentStyle ?
+                               el.currentStyle[property] :
+                               document.defaultView.getComputedStyle(el, 
null).getPropertyValue(property);
                },
                getMaximumSize = helpers.getMaximumSize = 
helpers.getMaximumWidth, // legacy support
                retinaScale = helpers.retinaScale = function(chart){
@@ -844,7 +918,7 @@
                },
                stop : function(){
                        // Stops any current animation loop occuring
-                       cancelAnimFrame(this.animationFrame);
+                       Chart.animationService.cancelAnimation(this);
                        return this;
                },
                resize : function(callback){
@@ -868,15 +942,26 @@
                        if (reflow){
                                this.reflow();
                        }
+                       
                        if (this.options.animation && !reflow){
-                               helpers.animationLoop(
-                                       this.draw,
-                                       this.options.animationSteps,
-                                       this.options.animationEasing,
-                                       this.options.onAnimationProgress,
-                                       this.options.onAnimationComplete,
-                                       this
-                               );
+                               var animation = new Chart.Animation();
+                               animation.numSteps = 
this.options.animationSteps;
+                               animation.easing = this.options.animationEasing;
+                               
+                               // render function
+                               animation.render = function(chartInstance, 
animationObject) {
+                                       var easingFunction = 
helpers.easingEffects[animationObject.easing];
+                                       var stepDecimal = 
animationObject.currentStep / animationObject.numSteps;
+                                       var easeDecimal = 
easingFunction(stepDecimal);
+                                       
+                                       chartInstance.draw(easeDecimal, 
stepDecimal, animationObject.currentStep);
+                               };
+                               
+                               // user events
+                               animation.onAnimationProgress = 
this.options.onAnimationProgress;
+                               animation.onAnimationComplete = 
this.options.onAnimationComplete;
+                               
+                               Chart.animationService.addAnimation(this, 
animation);
                        }
                        else{
                                this.draw();
@@ -1015,7 +1100,7 @@
                                                labels: tooltipLabels,
                                                legendColors: tooltipColors,
                                                legendColorBackground : 
this.options.multiTooltipKeyBackground,
-                                               title: ChartElements[0].label,
+                                               title: 
template(this.options.tooltipTitleTemplate,ChartElements[0]),
                                                chart: this.chart,
                                                ctx: this.chart.ctx,
                                                custom: 
this.options.customTooltips
@@ -1283,6 +1368,16 @@
                }
        });
 
+       Chart.Animation = Chart.Element.extend({
+               currentStep: null, // the current animation step
+               numSteps: 60, // default number of steps
+               easing: "", // the easing to use for this animation
+               render: null, // render function used by the animation service
+               
+               onAnimationProgress: null, // user specified callback to fire 
on each step of the animation 
+               onAnimationComplete: null, // user specified callback to fire 
when the animation finishes
+       });
+       
        Chart.Tooltip = Chart.Element.extend({
                draw : function(){
 
@@ -1466,7 +1561,7 @@
                        for (var i=0; i<=this.steps; i++){
                                
this.yLabels.push(template(this.templateString,{value:(this.min + (i * 
this.stepValue)).toFixed(stepDecimalPlaces)}));
                        }
-                       this.yLabelWidth = (this.display && this.showLabels) ? 
longestText(this.ctx,this.font,this.yLabels) : 0;
+                       this.yLabelWidth = (this.display && this.showLabels) ? 
longestText(this.ctx,this.font,this.yLabels) + 10 : 0;
                },
                addXLabel : function(label){
                        this.xLabels.push(label);
@@ -1489,6 +1584,9 @@
                        // Apply padding settings to the start and end point.
                        this.startPoint += this.padding;
                        this.endPoint -= this.padding;
+
+                       // Cache the starting endpoint, excluding the space for 
x labels
+                       var cachedEndPoint = this.endPoint;
 
                        // Cache the starting height, so can determine if we 
need to recalculate the scale yAxis
                        var cachedHeight = this.endPoint - this.startPoint,
@@ -1521,6 +1619,7 @@
 
                                // Only go through the xLabel loop again if the 
yLabel width has changed
                                if (cachedYLabelWidth < this.yLabelWidth){
+                                       this.endPoint = cachedEndPoint;
                                        this.calculateXLabelRotation();
                                }
                        }
@@ -1539,7 +1638,7 @@
 
 
                        this.xScalePaddingRight = lastWidth/2 + 3;
-                       this.xScalePaddingLeft = (firstWidth/2 > 
this.yLabelWidth + 10) ? firstWidth/2 : this.yLabelWidth + 10;
+                       this.xScalePaddingLeft = (firstWidth/2 > 
this.yLabelWidth) ? firstWidth/2 : this.yLabelWidth;
 
                        this.xLabelRotation = 0;
                        if (this.display){
@@ -1558,7 +1657,7 @@
                                        lastRotated = cosRotation * lastWidth;
 
                                        // We're right aligning the text now.
-                                       if (firstRotated + this.fontSize / 2 > 
this.yLabelWidth + 8){
+                                       if (firstRotated + this.fontSize / 2 > 
this.yLabelWidth){
                                                this.xScalePaddingLeft = 
firstRotated + this.fontSize / 2;
                                        }
                                        this.xScalePaddingRight = 
this.fontSize/2;
@@ -1984,6 +2083,90 @@
                }
        });
 
+       Chart.animationService = {
+               frameDuration: 17,
+               animations: [],
+               dropFrames: 0,
+               addAnimation: function(chartInstance, animationObject) {
+                       for (var index = 0; index < this.animations.length; ++ 
index){
+                               if (this.animations[index].chartInstance === 
chartInstance){
+                                       // replacing an in progress animation
+                                       this.animations[index].animationObject 
= animationObject;
+                                       return;
+                               }
+                       }
+                       
+                       this.animations.push({
+                               chartInstance: chartInstance,
+                               animationObject: animationObject
+                       });
+
+                       // If there are no animations queued, manually 
kickstart a digest, for lack of a better word
+                       if (this.animations.length == 1) {
+                               helpers.requestAnimFrame.call(window, 
this.digestWrapper);
+                       }
+               },
+               // Cancel the animation for a given chart instance
+               cancelAnimation: function(chartInstance) {
+                       var index = helpers.findNextWhere(this.animations, 
function(animationWrapper) {
+                               return animationWrapper.chartInstance === 
chartInstance;
+                       });
+                       
+                       if (index)
+                       {
+                               this.animations.splice(index, 1);
+                       }
+               },
+               // calls startDigest with the proper context
+               digestWrapper: function() {
+                       
Chart.animationService.startDigest.call(Chart.animationService);
+               },
+               startDigest: function() {
+
+                       var startTime = Date.now();
+                       var framesToDrop = 0;
+
+                       if(this.dropFrames > 1){
+                               framesToDrop = Math.floor(this.dropFrames);
+                               this.dropFrames -= framesToDrop;
+                       }
+
+                       for (var i = 0; i < this.animations.length; i++) {
+
+                               if 
(this.animations[i].animationObject.currentStep === null){
+                                       
this.animations[i].animationObject.currentStep = 0;
+                               }
+
+                               this.animations[i].animationObject.currentStep 
+= 1 + framesToDrop;
+                               
if(this.animations[i].animationObject.currentStep > 
this.animations[i].animationObject.numSteps){
+                                       
this.animations[i].animationObject.currentStep = 
this.animations[i].animationObject.numSteps;
+                               }
+                               
+                               
this.animations[i].animationObject.render(this.animations[i].chartInstance, 
this.animations[i].animationObject);
+                               
+                               if 
(this.animations[i].animationObject.currentStep == 
this.animations[i].animationObject.numSteps){
+                                       // executed the last frame. Remove the 
animation.
+                                       this.animations.splice(i, 1);
+                                       // Keep the index in place to offset 
the splice
+                                       i--;
+                               }
+                       }
+
+                       var endTime = Date.now();
+                       var delay = endTime - startTime - this.frameDuration;
+                       var frameDelay = delay / this.frameDuration;
+
+                       if(frameDelay > 1){
+                               this.dropFrames += frameDelay;
+                       }
+
+                       // Do we have more stuff to animate?
+                       if (this.animations.length > 0){
+                               helpers.requestAnimFrame.call(window, 
this.digestWrapper);
+                       }
+               }
+       };
+
        // Attach global event to resize each chart instance when the browser 
resizes
        helpers.addEvent(window, "resize", (function(){
                // Basic debounce of resize function so it doesn't hurt 
performance when resizing browser.
diff --git a/lib/chart.js/Chart.Line.js b/lib/chart.js/Chart.Line.js
index 34ad85b..fdf1b01 100644
--- a/lib/chart.js/Chart.Line.js
+++ b/lib/chart.js/Chart.Line.js
@@ -50,7 +50,10 @@
                datasetFill : true,
 
                //String - A legend template
-               legendTemplate : "<ul 
class=\"<%=name.toLowerCase()%>-legend\"><% for (var i=0; i<datasets.length; 
i++){%><li><span 
style=\"background-color:<%=datasets[i].strokeColor%>\"></span><%if(datasets[i].label){%><%=datasets[i].label%><%}%></li><%}%></ul>"
+               legendTemplate : "<ul 
class=\"<%=name.toLowerCase()%>-legend\"><% for (var i=0; i<datasets.length; 
i++){%><li><span 
style=\"background-color:<%=datasets[i].strokeColor%>\"><%if(datasets[i].label){%><%=datasets[i].label%><%}%></span></li><%}%></ul>",
+
+               //Boolean - Whether to horizontally center the label and point 
dot inside the grid
+               offsetGridLines : false
 
        };
 
@@ -61,6 +64,7 @@
                initialize:  function(data){
                        //Declare the extension of the default point, to cater 
for the options passed in to the constructor
                        this.PointClass = Chart.Point.extend({
+                               offsetGridLines : this.options.offsetGridLines,
                                strokeWidth : this.options.pointDotStrokeWidth,
                                radius : this.options.pointDotRadius,
                                display: this.options.pointDot,
@@ -176,6 +180,7 @@
                                width : this.chart.width,
                                ctx : this.chart.ctx,
                                textColor : this.options.scaleFontColor,
+                               offsetGridLines : this.options.offsetGridLines,
                                fontSize : this.options.scaleFontSize,
                                fontStyle : this.options.scaleFontStyle,
                                fontFamily : this.options.scaleFontFamily,
@@ -226,6 +231,7 @@
                                this.datasets[datasetIndex].points.push(new 
this.PointClass({
                                        value : value,
                                        label : label,
+                                       datasetLabel: 
this.datasets[datasetIndex].label,
                                        x: 
this.scale.calculateX(this.scale.valuesCount+1),
                                        y: this.scale.endPoint,
                                        strokeColor : 
this.datasets[datasetIndex].pointStrokeColor,
@@ -269,6 +275,7 @@
                                return helpers.findPreviousWhere(collection, 
hasValue, index) || point;
                        };
 
+                       if (!this.scale) return;
                        this.scale.draw(easingDecimal);
 
 
@@ -288,7 +295,7 @@
                                },this);
 
 
-                               // Control points need to be calculated in a 
seperate loop, because we need to know the current x/y of the point
+                               // Control points need to be calculated in a 
separate loop, because we need to know the current x/y of the point
                                // This would cause issues when there is no 
animation, because the y of the next point would be 0, so beziers would be 
skewed
                                if (this.options.bezierCurve){
                                        helpers.each(pointsWithValues, 
function(point, index){
@@ -349,7 +356,9 @@
                                        }
                                }, this);
 
-                               ctx.stroke();
+                               if (this.options.datasetStroke) {
+                                       ctx.stroke();
+                               }
 
                                if (this.options.datasetFill && 
pointsWithValues.length > 0){
                                        //Round off the line by going to the 
base of the chart, back to the start, then fill.
diff --git a/modules/stats/ext.cx.stats.js b/modules/stats/ext.cx.stats.js
index 17ce811..7c72029 100644
--- a/modules/stats/ext.cx.stats.js
+++ b/modules/stats/ext.cx.stats.js
@@ -44,6 +44,18 @@
                        height: 400
                } );
 
+               this.$translatonTrendBarChart = $( '<canvas>' ).attr( {
+                       id: 'cxtrendchart',
+                       width: this.$container.width() - 200, // Leave a 200px 
margin buffer to avoid overflow
+                       height: 400
+               } );
+
+               this.$langTranslatonTrendBarChart = $( '<canvas>' ).attr( {
+                       id: 'cxlangtrendchart',
+                       width: this.$container.width() - 200, // Leave a 200px 
margin buffer to avoid overflow
+                       height: 400
+               } );
+
                this.$container.append(
                        $spinner,
                        this.$highlights,
@@ -57,7 +69,18 @@
                        ).escaped() ),
                        $( '<div>' )
                                .addClass( 'cx-stats-graph 
cx-stats-cumulative-lang' )
-                               .append( this.$languageCumulativeGraph )
+                               .append( this.$languageCumulativeGraph ),
+                       $( '<h2>' ).text( mw.msg( 
'cx-trend-published-translations-title' ) ),
+                       $( '<div>' )
+                               .addClass( 'cx-stats-graph 
cx-stats-trend-total' )
+                               .append( this.$translatonTrendBarChart ),
+                       $( '<h2>' ).text( mw.message(
+                               'cx-trend-translations-to-title',
+                               $.uls.data.getAutonym( mw.config.get( 
'wgContentLanguage' ) )
+                       ).escaped() ),
+                       $( '<div>' )
+                               .addClass( 'cx-stats-graph cx-stats-trend-lang' 
)
+                               .append( this.$langTranslatonTrendBarChart )
                );
 
                $.when(
@@ -73,6 +96,8 @@
                        self.renderHighlights();
                        self.drawCumulativeGraph( 'count' );
                        self.drawLanguageCumulativeGraph( 'count' );
+                       self.drawTranslationTrend();
+                       self.drawLangTranslationTrend();
                } );
                this.getCXStats().then( function ( data ) {
                        if ( !data || !data.query ) {
@@ -93,7 +118,7 @@
                        $parenthesizedTrend, $trendInLanguage,
                        fmt = mw.language.convertNumber; // Shortcut
 
-               getTrend = function( data ) {
+               getTrend = function ( data ) {
                        var total, trend, thisWeek;
 
                        if ( data.length < 3 ) {
@@ -459,7 +484,6 @@
                        datasets: [
                                {
                                        label: mw.msg( 
'cx-trend-all-translations' ),
-                                       fillColor: '#347BFF',
                                        strokeColor: '#347BFF',
                                        pointColor: '#347BFF',
                                        pointStrokeColor: '#fff',
@@ -471,7 +495,6 @@
                                },
                                {
                                        label:  mw.msg( 
'cx-stats-draft-translations-title' ),
-                                       fillColor: '#777',
                                        strokeColor: '#777',
                                        pointColor: '#777',
                                        pointStrokeColor: '#fff',
@@ -518,10 +541,10 @@
                                {
                                        label: mw.msg( 'cx-trend-deletions' ),
                                        strokeColor: '#FF0000',
-                                       pointColor: '#FF0000',
+                                       pointColor: 'FF0000',
                                        pointStrokeColor: '#fff',
                                        pointHighlightFill: '#fff',
-                                       pointHighlightStroke: '#FF0000',
+                                       pointHighlightStroke: 'FF0000',
                                        data: $.map( 
this.languageDeletionTrend, function ( data ) {
                                                return data[ type ];
                                        } )
@@ -553,6 +576,114 @@
                this.$container.find( '.cx-stats-cumulative-lang' ).append( 
cxCumulativeGraph.generateLegend() );
        };
 
+       CXStats.prototype.drawTranslationTrend = function () {
+               var data, cxTrendChart, ctx, type = 'delta';
+
+               ctx = this.$translatonTrendBarChart[ 0 ].getContext( '2d' );
+               data = {
+                       labels: $.map( this.totalTranslationTrend, function ( 
data ) {
+                               return data.date;
+                       } ),
+                       datasets: [
+                               {
+                                       label: mw.msg( 
'cx-trend-all-translations' ),
+                                       strokeColor: '#347BFF',
+                                       fillColor: '#347BFF',
+                                       pointColor: '#347BFF',
+                                       pointStrokeColor: '#fff',
+                                       pointHighlightFill: '#fff',
+                                       pointHighlightStroke: '#347BFF',
+                                       data: $.map( 
this.totalTranslationTrend, function ( data ) {
+                                               return data[ type ];
+                                       } )
+                               },
+                               {
+                                       label:  mw.msg( 
'cx-stats-draft-translations-title' ),
+                                       strokeColor: '#777',
+                                       fillColor: '#777',
+                                       pointColor: '#777',
+                                       pointStrokeColor: '#fff',
+                                       pointHighlightFill: '#fff',
+                                       pointHighlightStroke: '#777',
+                                       data: $.map( this.totalDraftTrend, 
function ( data ) {
+                                               return data[ type ];
+                                       } )
+                               }
+                       ]
+               };
+
+               /*global Chart:false */
+               cxTrendChart = new Chart( ctx ).Bar( data, {
+                       responsive: true,
+                       barDatasetSpacing: 0,
+                       legendTemplate: '<ul><% for (var i=0; 
i<datasets.length; i++){%><li 
style=\"color:<%=datasets[i].strokeColor%>\"><%if(datasets[i].label){%><%=datasets[i].label%><%}%></li><%}%></ul>'
+               } );
+
+               this.$container.find( '.cx-stats-trend-total' ).append( 
cxTrendChart.generateLegend() );
+       };
+
+       CXStats.prototype.drawLangTranslationTrend = function () {
+               var ctx, data, cxTrendChart,
+                       type = 'delta';
+
+               ctx = this.$langTranslatonTrendBarChart[ 0 ].getContext( '2d' );
+               data = {
+                       labels: $.map( this.languageTranslationTrend, function 
( data ) {
+                               return data.date;
+                       } ),
+                       datasets: [
+                               {
+                                       label: mw.message(
+                                               'cx-trend-translations-to',
+                                               $.uls.data.getAutonym( 
mw.config.get( 'wgContentLanguage' ) )
+                                       ).escaped(),
+                                       strokeColor: '#347BFF',
+                                       fillColor: '#347BFF',
+                                       pointColor: '#347BFF',
+                                       pointStrokeColor: '#fff',
+                                       pointHighlightFill: '#fff',
+                                       pointHighlightStroke: '#347BFF',
+                                       data: $.map( 
this.languageTranslationTrend, function ( data ) {
+                                               return data[ type ];
+                                       } )
+                               },
+                               {
+                                       label:  mw.msg( 
'cx-stats-draft-translations-title' ),
+                                       strokeColor: '#777',
+                                       fillColor: '#777',
+                                       pointColor: '#777',
+                                       pointStrokeColor: '#fff',
+                                       pointHighlightFill: '#fff',
+                                       pointHighlightStroke: '#777',
+                                       data: $.map( this.languageDraftTrend, 
function ( data ) {
+                                               return data[ type ];
+                                       } )
+                               },
+                               {
+                                       label:  mw.msg( 'cx-trend-deletions' ),
+                                       strokeColor: '#FF0000',
+                                       fillColor: '#FF0000',
+                                       pointColor: '#FF0000',
+                                       pointStrokeColor: '#fff',
+                                       pointHighlightFill: '#fff',
+                                       pointHighlightStroke: '#FF0000',
+                                       data: $.map( 
this.languageDeletionTrend, function ( data ) {
+                                               return data[ type ];
+                                       } )
+                               }
+                       ]
+               };
+
+               /*global Chart:false */
+               cxTrendChart = new Chart( ctx ).Bar( data, {
+                       responsive: true,
+                       barDatasetSpacing: 0,
+                       legendTemplate: '<ul><% for (var i=0; 
i<datasets.length; i++){%><li 
style=\"color:<%=datasets[i].strokeColor%>\"><%if(datasets[i].label){%><%=datasets[i].label%><%}%></li><%}%></ul>'
+               } );
+
+               this.$container.find( '.cx-stats-trend-lang' ).append( 
cxTrendChart.generateLegend() );
+       };
+
        CXStats.prototype.transformJsonToModel = function ( records ) {
                var i, record, language, status, count, translators,
                        sourceLanguage, targetLanguage,
diff --git a/modules/stats/styles/ext.cx.stats.less 
b/modules/stats/styles/ext.cx.stats.less
index 0039f67..3c4ce11 100644
--- a/modules/stats/styles/ext.cx.stats.less
+++ b/modules/stats/styles/ext.cx.stats.less
@@ -160,6 +160,22 @@
 
 .cx-stats-graph {
        background-color: #fff;
+       border: 1px solid #999;
+
+       // Legend styling
+       li {
+               display: inline;
+               padding-right: 20px;
+
+               &:before {
+                       content: '\25A0';
+                       max-width: 0;
+                       max-height: 0;
+                       left: -10px;
+                       top: 0;
+                       font-size: 20px;
+               }
+       }
 }
 
 .cx-stats-highlights {

-- 
To view, visit https://gerrit.wikimedia.org/r/236764
To unsubscribe, visit https://gerrit.wikimedia.org/r/settings

Gerrit-MessageType: merged
Gerrit-Change-Id: I8301bcd7e595e8144a43622a27503b8ed9494bbf
Gerrit-PatchSet: 7
Gerrit-Project: mediawiki/extensions/ContentTranslation
Gerrit-Branch: master
Gerrit-Owner: Santhosh <[email protected]>
Gerrit-Reviewer: Amire80 <[email protected]>
Gerrit-Reviewer: Nikerabbit <[email protected]>
Gerrit-Reviewer: Siebrand <[email protected]>
Gerrit-Reviewer: jenkins-bot <>

_______________________________________________
MediaWiki-commits mailing list
[email protected]
https://lists.wikimedia.org/mailman/listinfo/mediawiki-commits

Reply via email to