wikimedia/wikimedia-fundraising-dash

View on GitHub
src/components/widgets/totals-earned-chart/totals-earned-chart.js

Summary

Maintainability
F
4 days
Test Coverage
define( [
    'knockout',
    'text!components/widgets/totals-earned-chart/totals-earned-chart.html',
    'c3',
    'numeraljs',
    'momentjs',
    'Campaign',
    'WidgetBase'
], function ( ko, template, c3, numeral, moment, Campaign, WidgetBase ) {

    function initializedSharedContext( params ) {
        // initialize day/hour data
        // sharing these for other widgets
        params.sharedContext.dayObj = [];
        params.sharedContext.dailyDataArray = [ 'Daily Total' ];
        params.sharedContext.dailyCountArray = [ 'Daily Count' ];
        params.sharedContext.lastDataPoint = { day: 1, hour: 0 };
        params.sharedContext.secondsByHourDonationData = [ 'Donations Per Second' ];
    }

    function TotalsEarnedChartViewModel( params ) {

        var self = this,
            timeFormat = 'dddd, MMMM Do YYYY, h:mm:ss a',
            localUtcOffset = moment().utcOffset();

        WidgetBase.call( this, params );

        initializedSharedContext( params );

        // Get the date
        self.displayDate = ko.observable( moment().format( timeFormat ) );
        self.showChart = ko.observable( '' );
        self.hourlyChart = ko.observable( false );
        self.dailyChart = ko.observable( false );

        self.majorDonationCutoff = ko.observable( self.config.majorDonationCutoff || 1000 ).extend( { throttle: 500 } );

        self.campaigns = [
            new Campaign( {
                name: '2020',
                startDate: Date.UTC( 2019, 9, 1 ),
                endDate: Date.UTC( 2020, 0, 1 ),
                target: 60000000
            } ),

            new Campaign( {
                name: '2019',
                startDate: Date.UTC( 2019, 9, 1 ),
                endDate: Date.UTC( 2020, 0, 1 ),
                target: 59400000
            } ),
            new Campaign( {
                name: '2018',
                startDate: Date.UTC( 2018, 9, 1 ),
                endDate: Date.UTC( 2019, 0, 1 ),
                target: 57500000
            } ),
            new Campaign( {
                name: '2017',
                startDate: Date.UTC( 2017, 9, 2 ),
                endDate: Date.UTC( 2018, 0, 1 ),
                target: 51000000
            } ),
            new Campaign( {
                name: '2016',
                startDate: Date.UTC( 2016, 10, 29 ),
                endDate: Date.UTC( 2017, 0, 1 )
            } ),
            new Campaign( {
                name: '2015',
                startDate: Date.UTC( 2015, 11, 1 ),
                endDate: Date.UTC( 2016, 0, 1 )
            } ),
            new Campaign( {
                name: '2014',
                startDate: Date.UTC( 2014, 11, 2 ),
                endDate: Date.UTC( 2015, 0, 1 )
            } ),
            new Campaign( {
                name: '2013',
                startDate: Date.UTC( 2013, 11, 3 ),
                endDate: Date.UTC( 2014, 0, 1 )
            } ),
            new Campaign( {
                name: '2012',
                startDate: Date.UTC( 2012, 10, 27 ),
                endDate: Date.UTC( 2013, 0, 1 )
            } ),
            new Campaign( {
                name: '2011',
                startDate: Date.UTC( 2011, 10, 16 ),
                endDate: Date.UTC( 2012, 0, 1 )
            } )
        ];
        self.campaign = ko.observable( self.campaigns[ 0 ] );
        self.goal = params.sharedContext.goal = ko.observable( self.config.goal || self.campaign().target );
        self.includeEndowment = ko.observable( self.config.includeEndowment || false );

        self.isCurrentYear = ko.computed( function () {
            return self.campaign() === self.campaigns[ 0 ];
        } );

        // FIXME: do this stuff on 'Submit', actually cancel changes on 'Cancel'
        self.disposables.push( params.sharedContext.goal.subscribe( function () {
            self.config.goal = params.sharedContext.goal();
            self.logStateChange();
        } ) );

        self.disposables.push( self.includeEndowment.subscribe( function () {
            self.config.includeEndowment = self.includeEndowment();
            self.logStateChange();
            self.reloadData();
        } ) );

        self.disposables.push( self.majorDonationCutoff.subscribe( function () {
            self.config.majorDonationCutoff = self.majorDonationCutoff();
            self.logStateChange();
            self.reloadData();
        } ) );

        self.disposables.push( self.campaign.subscribe( function () {
            self.goal( self.campaign().target );
            self.logStateChange();
            self.reloadData();
        } ) );

        self.raised = ko.observable( 0 );

        // Let other widgets subscribe to changes in the goal or the totals
        params.sharedContext.totalsChanged = ko.computed( function () {
            return self.raised() - params.sharedContext.goal();
        } );

        self.formattedGoal = ko.computed( function () {
            return numeral( params.sharedContext.goal() ).format( '$0,0' );
        } );

        self.totalRaisedToDate = ko.computed( function () {
            return numeral( self.raised() ).format( '$0,0' );
        } );

        self.totalRemainingToDate = ko.computed( function () {
            var trtd = params.sharedContext.goal() - self.raised();
            return numeral( trtd >= 0 ? trtd : 0 ).format( '$0,0' );
        } );

        // get the data needed for this chart
        self.loadData = function ( data, timestamp ) {
            var runningTotal = 0,
                currentDate = new Date(),
                lastData,
                days = self.campaign().getLengthInDays(),
                offset = self.campaign().getDayOfYearOffset(),
                d,
                h,
                i,
                el,
                day,
                hour,
                total,
                seconds,
                dataCount,
                ms;

            initializedSharedContext( params );
            lastData = params.sharedContext.lastDataPoint;

            currentDate.setTime( timestamp );
            self.displayDate( moment( currentDate ).format( timeFormat ) );

            for ( d = 1; d < days + 1; d++ ) {
                params.sharedContext.campaignLength = days;
                params.sharedContext.dailyDataArray[ d ] = 0;
                params.sharedContext.dailyCountArray[ d ] = 0;
                if ( !params.sharedContext.dayObj[ d ] ) {
                    params.sharedContext.dayObj[ d ] = new Array( 25 );
                    params.sharedContext.dayObj[ d ][ 0 ] = 'Hourly Totals';
                    for ( h = 0; h < 24; h++ ) {
                        params.sharedContext.dayObj[ d ][ h + 1 ] = { total: 0, count: 0 };
                        params.sharedContext.secondsByHourDonationData[ ( d - 1 ) * 24 + h + 1 ] = 0;
                    }
                }
            }

            dataCount = data.length;
            for ( i = 0; i < dataCount; i++ ) {

                el = data[ i ];
                day = el.day - offset;
                hour = el.hour;
                total = el.usd_total;
                seconds = Math.max( el.minutes * 60, 60 ); // Don't divide by zero
                params.sharedContext.dayObj[ day ][ hour + 1 ] = { total: total, count: el.donations };

                params.sharedContext.secondsByHourDonationData[ ( day - 1 ) * 24 + hour + 1 ] = el.usd_total / seconds;
                runningTotal += total;
                params.sharedContext.dailyDataArray[ day ] += total;
                params.sharedContext.dailyCountArray[ day ] += el.donations;
            }

            if ( self.isCurrentYear() && currentDate.getTime() < self.campaign().getEndDate().getTime() ) {
                ms = currentDate.getTime() - self.campaign().getStartDate().getTime();
                lastData.day = Math.floor( ms / ( 24 * 60 * 60 * 1000 ) ) + 1;
                lastData.hour = currentDate.getUTCHours();
            } else if ( dataCount > 0 ) {
                lastData.day = data[ dataCount - 1 ].day - offset;
                lastData.hour = data[ dataCount - 1 ].hour;
            } else {
                lastData.day = 1;
                lastData.hour = 0;
            }

            self.makeCharts();

            self.raised( runningTotal );
        };

        // Reload the data.  For the automatic reload, we're fine getting
        // something from the cache.
        self.reloadData = function ( automatic ) {
            // FIXME: use some common filter logic
            var qs = '$filter=' + self.campaign().getDateFilter() + ' and ' +
                    'Amount lt \'' + self.majorDonationCutoff() + '\'',
                interval = 500000,
                firstLoad = ( self.raised() === 0 ),
                threshold = interval + self.raised() - self.raised() % interval;

            if ( self.includeEndowment() === false ) {
                qs += ' and IsEndowment eq \'0\'';
            }
            if ( automatic !== true ) {
                qs += '&cache=false';
            }

            self.getChartData( qs, function ( dataget ) {
                self.loadData( dataget.results, dataget.timestamp );
                if ( !firstLoad && self.raised() > threshold ) {
                    $( '.credit' ).fadeIn( 'slow' );
                    document.getElementById( 'ding-a-ling' ).play();
                    setTimeout( function () {
                        $( '.credit' ).fadeOut( 'slow' );
                    }, 6000 );
                }
            } );
            if ( self.isCurrentYear() ) {
                // Do it every 5 minutes as well
                self.timers.push( setTimeout( function () {
                    self.reloadData( true );
                }, 300000 ) );
            }
        };

        self.reloadData( true );

        self.makeCharts = function () {
            if ( params.sharedContext.dailyDataArray.length < 2 ) {
                return;
            }
            self.showChart( '' );
            self.dailyChart( self.makeDailyChart() );
            self.showChart( 'daily' );
        };

        params.sharedContext.getDay = function ( dayNum ) {
            var result = moment( self.campaign().getStartDate() );
            result.subtract( localUtcOffset, 'm' );
            result.add( dayNum, 'd' );
            return result.format( 'MMM D' );
        };

        self.makeHourlyChart = function ( d /* , i */ ) {
            var hourlyData = params.sharedContext.dayObj[ d.x + 1 ],
                hourlyCountArray = [ 'Hourly Count' ],
                hourlyTotalArray = [ 'Hourly Total' ],
                j;
            for ( j = 1; j < 25; j++ ) {
                hourlyCountArray.push( hourlyData[ j ].count );
                hourlyTotalArray.push( hourlyData[ j ].total );
            }
            return {
                size: {
                    height: 450,
                    width: window.width
                },
                zoom: { enabled: true },
                data: {
                    columns: [ hourlyTotalArray, hourlyCountArray ],
                    type: 'bar',
                    colors: { 'Hourly Total': 'rgb(92,184,92)', 'Hourly Count': '#f0ad4e' },
                    onclick: function ( /* d, i */ ) {
                        self.showChart( '' );
                        self.dailyChart( self.makeDailyChart() );
                        self.showChart( 'daily' );
                    },
                    axes: {
                        'Hourly Total': 'y',
                        'Hourly Count': 'y2'
                    }
                },
                grid: {
                    x: {
                        show: true
                    },
                    y: {
                        show: true
                    }
                },
                axis: {
                    x: {
                        label: {
                            text: params.sharedContext.getDay( d.x ),
                            position: 'outer-left'
                        },
                        tick: {
                            format: function ( x ) { return x + ':00'; }
                        }
                    },
                    y: {
                        tick: {
                            format: function ( x ) { return numeral( x ).format( '$0,0' ); }
                        }
                    },
                    y2: {
                        tick: {
                            format: function ( x ) { return numeral( x ).format( '0,0' ); }
                        },
                        show: true
                    }
                },
                tooltip: {
                    format: {
                        title: function ( d ) { return 'Hour ' + d; },
                        value: function ( value, ratio, id ) {
                            var display;
                            if ( id === 'Hourly Total' ) {
                                display = numeral( value ).format( '$0,0' );
                            } else {
                                display = numeral( value ).format( '0,0' );
                            }
                            return display;
                        }
                    }
                },
                bar: {
                    width: {
                        ratio: 0.5
                    }
                }
            };
        };

        self.makeDailyChart = function ( /* d, i */ ) {
            return {
                size: {
                    height: 450,
                    width: window.width
                },
                zoom: { enabled: true },
                data: {
                    columns: [ params.sharedContext.dailyDataArray, params.sharedContext.dailyCountArray ],
                    type: 'bar',
                    colors: { 'Daily Total': 'rgb(49,176,213)', 'Daily Count': '#f0ad4e' },
                    onclick: function ( d, i ) {
                        self.showChart( '' );
                        self.hourlyChart( self.makeHourlyChart( d, i ) );
                        self.showChart( 'hourly' );
                    },
                    axes: {
                        'Daily Total': 'y',
                        'Daily Count': 'y2'
                    }
                },
                grid: {
                    x: {
                        show: true
                    },
                    y: {
                        show: true
                    }
                },
                axis: {
                    x: {
                        tick: {
                            format: params.sharedContext.getDay
                        }
                    },
                    y: {
                        tick: {
                            format: function ( x ) { return numeral( x ).format( '$0,0' ); }
                        }
                    },
                    y2: {
                        tick: {
                            format: function ( x ) { return numeral( x ).format( '0,0' ); }
                        },
                        show: true
                    }
                },
                tooltip: {
                    format: {
                        title: params.sharedContext.getDay,
                        value: function ( value, ratio, id ) {
                            var display;
                            if ( id === 'Daily Total' ) {
                                display = numeral( value ).format( '$0,0' );
                            } else {
                                display = numeral( value ).format( '0,0' );
                            }
                            return display;
                        }
                    }
                },
                bar: {
                    width: {
                        ratio: 0.5
                    }
                }
            };
        };
        self.makeCharts();
    }

    return { viewModel: TotalsEarnedChartViewModel, template: template };

} );