/* global $, jQuery */
/* global console_log, console_warn, console_error, console_trace */
/* global STR_DIRECTORY, EDITIONS, getEditionInfo, getEditionOrder */
/* global findByProperty, findIndexByProperty, cal_buildCalendar, doResize, resetUpPoint */
/* global isAccountVisible */
/* global OUTING_STATUS */
/* global BULLET, g_dbTables, g_calOptions, g_listAccounts */
/* global IMPLICIT_EVENT_OFFSETS */
/* global VISIBILITY */
/* global ROLLUP_NONE, ROLLUP_DAYS */
/* global getOutingName, isVirtual, isReminder, isCancelled, isImplicit, getRollupUnit, parseISO, addDaysToTimestamp, getSignupTimestamp, cloneEvent, parseOutingKey, generateOutingSortKey, sortOutingsByDate, isOutingSubscription, formatOutingLocation, formatOutingTimeRange, inferOutingKeyEdition, formatRollupCount, getOutingNightsFromLabels, uniquifyList */
/* global openLightBox, closeLightBox */
/* global getBadgeURL, getEventURL */
/* global getNow, size, parseEditionAudience, formatDate, getStartOfDayTimestamp, isEmbeddedImage, convertLinkToEmbeddedVideo, getTextForegroundColor */
/* global g_creole, highliteSearch_creole, g_strTag */
/* global getLocalStorage */
/* exported g_hasLocalStorage, g_config, g_jsonMyEdition */

// If the screen orientation is defined we are in a modern mobile OS
var g_isMobileOS = (typeof window.orientation != "undefined") || (navigator.userAgent.indexOf('IEMobile') !== -1); 	// eslint-disable-line no-unused-vars

var g_isFetchingOutings = false;
var g_mapAccountOutings = {};
var g_listVisibleSchedules = [];
var g_mapCachedOutings = {};
var BADGES_TABLES = "/proxy/";
var AJAX_TIMEOUT = 30000;                  	// thirty seconds

if ( typeof EDITION_ENUM == "undefined" )
    var EDITION_ENUM = -1;

var g_jsonMyEdition;				// eslint-disable-line init-declarations
if ( typeof EDITIONS != "undefined" )		// this might be dynamically loaded (e.g., in /hello/hello.js)
    g_jsonMyEdition = getEditionInfo( EDITION_ENUM );

// eslint-disable-next-line no-unused-vars
var START_OF_WEEK = 0;			// always use parseInt() when setting this!

var MONTHS = [ "Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec" ]; // eslint-disable-line no-unused-vars
var MONTHS_LONG = [ "January", "February", "March", "April", "May", "June", "July", "August", "September", "October", "November", "December" ]; // eslint-disable-line no-unused-vars
var DOW = [ "Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat" ];
var DOW_LONG = [ "Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday" ]; // eslint-disable-line no-unused-vars

var g_config = {
    currency: "$"		// needs to be localized in product-specific applications
};
var DATE_FORMAT = "yyyy-mmm-dd";	// needs to be localized in product-specific applications

var g_hasLocalStorage = false;

try {
    g_hasLocalStorage = 'localStorage' in window && window['localStorage'] !== null;
} catch ( e ) {
    // nop
}

function hasPermYouth()
{
    return false;
}

/* exported hasPermPost */
function hasPermPost()
{
    return false;
}

/* exported hasPermSchedule */
function hasPermSchedule()
{
    return false;
}

/* exported hasFeature_leaders */
function hasFeature_leaders()
{
    return true;
}

/* exported hasFeature_dues */
function hasFeature_dues()
{
    return true;
}

function highliteSearch( strText )
{
    return strText;
}

function getAccountID() 	// eslint-disable-line no-unused-vars
{
    return -1;
}

function getLoginID() 	// eslint-disable-line no-unused-vars
{
    return -1;
}

function getCurrentPage()  // eslint-disable-line no-unused-vars
{
    return "main";
}

function formatBadgeLink( editionEnum, badgeID, strLabel )
{
    return "<a target=_blank href='" + getBadgeURL( badgeID, getEditionInfo( editionEnum ).directory ) + "'>" + strLabel + "</a>";
}

function buildEventLink( outingKey )
{
    if ( ! outingKey.match( /^(\d+)-(\d+)-(\d+)-(.{20})$/ ) )
	return "";

    var otherEditionEnum = RegExp.$1;

    return getEventURL( outingKey, getEditionInfo( otherEditionEnum ).directory );
}

function formatEventLink( outingKey, strLabel )
{
    const jsonParsedKey = parseOutingKey( outingKey );
    if ( ! jsonParsedKey )
    {
	console_warn( `invalid event key "${outingKey}"` );
	return highliteSearch( strLabel );
    }

    return "<a target=_blank href='" + buildEventLink( outingKey ) + "'>" + strLabel + "</a>";
}

function linkifyDescription( strDescription, editionEnum, unusedParam_youthID, translateRoman, isCreole, includeDetails = true, embedVideos = false )	// eslint-disable-line no-unused-vars
{
    var listLinks = [];
    if ( strDescription === undefined  )
	return "";

    strDescription = strDescription.replace( /<leader>(.+)<\/leader>/gsm, hasPermYouth() ? "$1" : "" );

    if ( isCreole === undefined )
	isCreole = false;

    if ( ! isCreole )
	strDescription = strDescription.replace( /\n/g, " <!-- --> " );

    if ( embedVideos )
    {
	// iterate over the content, converting all youtube links to embedded videos
    	// look for something of the format: <a href="https://youtu.be/Rjhnu92lvv8" target="_blank">https://youtu.be/Rjhnu92lvv8</a>
	while ( strDescription.match( /(<a.+?href="([^"]+)".+?<\/a>)/ ) )				// look for creole definitions of links
	{
	    let strLinkVideo = RegExp.$1;			// this is the whole anchor element
	    const url = RegExp.$2;			// this is just the href

	    const strPlaceholderVideo = `_%${listLinks.length}%_`;
	    strDescription = strDescription.replace( /(<a.+?href="([^"]+)".+?<\/a>)/, strPlaceholderVideo );

	    const iframe = convertLinkToEmbeddedVideo( url );
	    if ( iframe )									// were we able to extract an ID?
		strLinkVideo = iframe;

	    listLinks.push( { link: strLinkVideo, re: new RegExp( strPlaceholderVideo ) } );
	}
    }

    while ( strDescription.match( /(\[\[.+?\]\])/ ) )
    {
	var strLink = RegExp.$1;

	var strPlaceholder = "_%" + listLinks.length + "%_";
	strDescription = strDescription.replace( /(\[\[.+?\]\])/, strPlaceholder );

	// external links #1 (href + text)
	strLink = strLink.replace( /\[\[(http[^|]+?)\|(.+?)\]\]/g, function( $0, $1, $2 ) {
	    return "<a target=_blank href='" + $1 + "'>" + highliteSearch( $2 ) + "</a>"
	});
	// external links #2 (href only)
	strLink = strLink.replace( /\[\[(http[^\]]+?)\]\]/g, "<a target=_blank href='$1'>$1</a>" );

	strLink = strLink.replace( /\[\[(mailto[^|]+?)\|(.+?)\]\]/g, function( $0, $1, $2 ) {
	    return "<a target=_blank href='" + $1 + "'>" + highliteSearch( $2 ) + "</a>"
	});
	strLink = strLink.replace( /\[\[(mailto[^\]]+?)\]\]/g, "<a target=_blank href='$1'>$1</a>" );

	// external links (relative) #1 (href + text)
	strLink = strLink.replace( /\[\[(\/[^|]+?)\|(.+?)\]\]/g, function( $0, $1, $2 ) {
	    return "<a target=_blank href='" + $1 + "'>" + highliteSearch( $2 ) + "</a>";
	});
	// internal links #1 (always href + text)
	strLink = strLink.replace( /\[\[badge:([^|]+?)\|(.+?)\]\]/g, function( $0, $1, $2 ) {
	    return formatBadgeLink( editionEnum, $1, $2 );
	});
	strLink = strLink.replace( /\[\[event:([^|]+?)\|(.+?)\]\]/g, function( $0, $1, $2 ) {
	    return formatEventLink( $1, $2 );
	});
	// internal links cannot contain a pipe '|' and strings must be entered as double quotes '"' (not apostrophes)
	strLink = strLink.replace( /\[\[internal:([^|]+?)\|(.+?)\]\]/g, function( $0, $1, $2 ) {
	    return "<a href='javascript:void(0);' onclick=\"" + $1 + "\">" + highliteSearch( $2 ) + "</a>"
	});
	// radio buttons
	strLink = strLink.replace( /\[\[radio-([^:]+):([^|]+?)\|(.+?)\]\]/g, function( $0, $1, $2, $3 ) {
	    var reqID = $1;
	    var strLabel = $3;
	    var htmlRadio = "<input type=hidden name=\"h-" + reqID + "\"/>";
	    $.each( $2.split(/,/), function( i, strText ) {
	         var id = "radio-" + reqID + "-" + i;
	         htmlRadio += " <span style='white-space:nowrap;'><input onclick=\"return doClickRadio('" + id + "');\" type=radio radioid=\"" + id + "\" name=\"" + reqID + "\" value=\"" + strText + "\"/><label onclick=\"return doClickRadio('" + id + "');\">" + highliteSearch( strText ) + "</label></span>";
	    });
	    return highliteSearch( strLabel ) + ( strLabel.match( /[ 0-9a-zA-z]$/ ) ? ": " : " " ) + htmlRadio;
	});

	listLinks.push( { link: strLink, re: new RegExp( strPlaceholder ) } );
    }

    // highlight all the instance of the search term
    if ( isCreole )
	strDescription = highliteSearch_creole( strDescription );
    else
	strDescription = highliteSearch( strDescription );

    // turn roman numerals into separate rows
    if ( translateRoman && strDescription.match( / (([-]|i{1,3}|i?v|vi{1,3}|i?x)\))/ ) )
    {
	// turn all -) i) ii) vii) p) into a table row
	strDescription = strDescription.replace( / (([-]|x?i{1,3}|x?i?v|x?vi{1,3}|x?i?x|p)\))\s*(.+?)(?= ([-]|x?i{1,3}|x?i?v|x?vi{1,3}|x?i?x|p)\))/g, "dac3500<tr><td style='padding-right:5px;text-align:right;vertical-align:top;width:1px;'>$2)</td><td>$3</td></tr>dac3501" );
	strDescription = strDescription.replace( / (([-]|x?i{1,3}|x?i?v|x?vi{1,3}|x?i?x|p)\))\s*(.+)/g, "dac3500<tr><td style='padding-right:5px;text-align:right;vertical-align:top;width:1px;'>$2)</td><td>$3</td></tr>dac3501" );

	strDescription = strDescription.replace( /dac3500\s*dac3501/g, "" );

	// turn p) into a new row with a colspan=2
	strDescription = strDescription.replace( /<td[^>]*?>p\)<\/td><td[^>]*?>(.*?)<\/td>/g, "<td colspan=2>$1</td>" );
	strDescription = strDescription.replace( /dac3500(.+)dac3501/g, "<table>$1</table>" );
	strDescription = strDescription.replace( /dac350\d/g, "" );

	// replace -) with the bullet character
	strDescription = strDescription.replace( /(<td[^>]*?>)-\)/g, "$1" + BULLET );
    }

    // hack to work around the fact that "<detail># item 1" doesn't get treated as a numbered item
    strDescription = strDescription.replace( /<detail>([#*])/, "<detail>\n$1" );

    // render, if necessary
    if ( isCreole )
    {
	$( "#creole-notes" ).empty();
	g_creole.parse( $( "#creole-notes" )[0], strDescription );

	strDescription = $( "#creole-notes" ).html();
    }

    if ( includeDetails )
	strDescription = strDescription.replace( /&lt;detail&gt;/g, "<div class='detail' style='display:none;'>" );
    else
	strDescription = strDescription.replace( /&lt;detail&gt;.*/g, "" );

    strDescription = strDescription.replace( /&lt;\/detail&gt;/g, "</div>" );
    strDescription = strDescription.replace( /&lt;span(.*?)&gt;/g, "<span$1>" );
    strDescription = strDescription.replace( /&lt;\/span&gt;/g, "</span>" );
    strDescription = strDescription.replace( /&lt;img\s+(.+?)\/&gt;/g, "<img $1/>" );

    // restore all the links
    while ( listLinks.length > 0 )
    {
	var placeholder = listLinks.shift();
	strDescription = strDescription.replace( placeholder.re, "<span>" + placeholder.link + "</span>" );
    }

    return strDescription;
}

// Return a new date object with the hours/minutes/days set to zero (local time), i.e., the START of the current day.
//
// See also getStartOfDayTimestamp() and getEndOfTimestamp() for convenience wrappers that return timestamps.
Date.prototype.getMidnight = function()
{
    return new Date( this.getFullYear(), this.getMonth(), this.getDate() );
}

// render the date in a flexible format
Date.prototype.format = function( strFormat )
{
    var month = null;
    var hours = null;
    var mins = null;

    if ( strFormat === undefined )
        strFormat = DATE_FORMAT;

    var days = this.getDate();

    if ( strFormat == "mmm d, 'YY" )
    {
        month = MONTHS[this.getMonth()];
        var year = this.getFullYear() - 2000;
        if ( year < 10 )
            year = "0" + year;
        return month + " " + days + ", '" + year;
    }

    else if ( strFormat == "mmm d" )
    {
        month = MONTHS[this.getMonth()];
        return month + " " + days;
    }

    else if ( strFormat == "ddd, mmm d" )
    {
        month = MONTHS[this.getMonth()];
        var dayofweek = DOW[this.getDay()];
        return dayofweek + ", " + month + " " + days;
    }

    else if ( strFormat == "YYYY-mmm-dd" ) {
        if ( days < 10 )
            days = "0" + days;
        month = MONTHS[this.getMonth()];
        return this.getFullYear() + "-" + month + "-" + days;
    }

    else if ( strFormat == "mm/dd/YYYY" ) {
        if ( days < 10 )
            days = "0" + days;
        month = this.getMonth() + 1;
        if ( month < 10 )
            month = "0" + month;
        return month + "/" + days + "/" + this.getFullYear();
    }

    else if ( strFormat == "dd/mm/YYYY" ) {
        if ( days < 10 )
            days = "0" + days;
        month = this.getMonth() + 1;
        if ( month < 10 )
            month = "0" + month;
        return days + "/" + month + "/" + this.getFullYear();
    }

    else if ( strFormat == "dd mmm YYYY" ) {
        if ( days < 10 )
            days = "0" + days;
        month = MONTHS[this.getMonth()];
        return days + " " + month + " " + this.getFullYear();
    }

    else if ( strFormat == "H:MM" ) {
        hours = this.getHours();
        mins = this.getMinutes();
        if ( mins < 10 )
            mins = "0" + mins;
        return hours + ":" + mins;
    }

    else if ( strFormat == "HH:MM" ) {
        hours = this.getHours();
        if ( hours < 10 )
            hours = "0" + hours;
        mins = this.getMinutes();
        if ( mins < 10 )
            mins = "0" + mins;
        return hours + ":" + mins;
    }

    else  // should be YYYY-mm-dd
    {
        if ( days < 10 )
            days = "0" + days;
        month = this.getMonth() + 1;
        if ( month < 10 )
            month = "0" + month;
        return this.getFullYear() + "-" + month + "-" + days;
    }
}

// return a new date some days later/earlier, with but with the hours/minutes/days preserved
Date.prototype.addDays = function( nDays )
{
    nDays = parseInt( nDays );
    var dateNew = new Date(this.getTime());
    dateNew.setDate( dateNew.getDate() + nDays );	// increment the day

    dateNew.setHours( this.getHours() );
    dateNew.setMinutes( this.getMinutes() );
    dateNew.setSeconds( this.getSeconds() );
    dateNew.setMilliseconds( this.getMilliseconds() );

    return dateNew;
}

// return a new date that is the end of the day (11:59:59)
Date.prototype.getEndOfDayTimestamp = function()
{
    var dateNew = new Date(this.getTime());
    dateNew.setDate( this.getDate() + 1 );	// increment the day

    dateNew.setHours( 0 );
    dateNew.setMinutes( 0 );
    dateNew.setSeconds( 0 );
    dateNew.setMilliseconds( 0 );

    return dateNew.getTime() - 1000;		// one second prior
}

function getOutingNights( outing, labels, stopTime )
{
    return getOutingNightsFromLabels( outing, labels, stopTime );
}

function insertEventIntoCalendar( cal, outing, editionEnum )
{
    if ( outing == null )
        return;

    var outingID = outing.outingid;
    var strDate = formatDate( outing.date, "yyyy-mm-dd" );	// MUST be ISO format

    // already added?
    if ( cal.find( "td[data-date=" + strDate + "] div[data-outingid=" + outingID + "]" ).length > 0 )
    {
	console_log( "event " + outingID + " already exists in calendar" );
	return;
    }

    // javascript event stuff to stop bubbling
    var jsOnClick = "";

    var htmlOuting = buildEventReportRow( getOutingMemberLabels( outing ), true, false, true );

    var strLocation = outing.location;
    if ( strLocation !== undefined && strLocation != null && strLocation != "" )
    {
	const isVirtualEvent = isVirtual( outing );
	const jsonLocation = formatOutingLocation( isVirtualEvent, outing.location, false );

	if ( isVirtualEvent )			// fix a zoom bug in which they include the password in all invites
	    jsonLocation.location = jsonLocation.location.replace( /[?]pwd=.+/, "" );

	// format URLs as very succinct links
	strLocation = jsonLocation.location.replace( /(http[^\s\]\)]+)/g, function( $0, $1 ) {
	    return `<a target=_blank title="${isVirtualEvent ? "Connect to the call" : "View map"}" href="${$1}" onclick="const evt = arguments[0] || window.event; evt.stopPropagation();">${isVirtualEvent ? "call-in details" : "map"}</a>`;
	});

	strLocation = "<br/><span class='location'>@&nbsp;" + strLocation.replace( /~%~/, "; " ).replace( /^; /, "" ).replace( /; $/, "") + "</span>";
    }
    else
	strLocation = "";

    var strTime = formatOutingTimeRange( outing );
    if ( strTime != "" )
	strTime = "<br/><span class='location'>" + strTime + "</span>";

    var strTooltip = " title='View event details' ";
    var strSection = "";
    var htmlLegendSwatch = "";

    var strBgColor = getAccountColor( outing.editionenum, outing.accountid );
    var strFgColor = getTextForegroundColor( strBgColor );
    var strName = "Unknown";
    strName = getAccountNameByKey( outing.accountkey );

    if ( strBgColor && g_listVisibleSchedules.length > 1 )
    {
	htmlLegendSwatch = "<div class='legend-swatch " + getEditionInfo( outing.editionenum ).edition + "' style='background-color:" + strBgColor + ";color:" + strFgColor + ";'>" + strName + "&nbsp;</div>";
	strSection = " s ";
    }

    if ( editionEnum !== undefined )	// is this the native account (as opposed to a collaboration account?)
	outingID = outing.outingkey;

    var timestamp = outing.date;
    if ( outing.isallday )
	timestamp = getStartOfDayTimestamp( outing.date );

    for ( var iAttachment = 0; iAttachment < outing.attachments.length; iAttachment++ )
    {
        if ( isEmbeddedImage( outing.attachments[iAttachment] ) )
	    continue;		// never display these to the user

	if ( outing.attachments[iAttachment].visibility != VISIBILITY.leaders.id )
	{
	    htmlOuting += "<img src='/common/images/paperclip.gif' style='float:right;margin-top:4px;'/>";
	    break;
	}
    }

    var strClasses = "";
    var htmlAudienceSwatches = "";
    if ( typeof g_strTag != "undefined" && g_strTag )
    {
	var listEditions = parseEditionAudience( outing.audience );

	if ( listEditions.length == 0  )	// if the event applies to all editions, then manually create list
	    for ( var i = 0; i < EDITIONS.length; i++ )
	        listEditions.push( EDITIONS[i].edition );

	for ( var iAudience = 0; iAudience < listEditions.length; iAudience++ )
	{
	    strClasses += (strClasses ? " " : "") + "audience-" + listEditions[iAudience];
	    if ( isAccountVisible( findIndexByProperty( EDITIONS, "edition", listEditions[iAudience] ) ) )
		htmlAudienceSwatches += "<div class=" + listEditions[iAudience] + " title='This event is for " + findByProperty( EDITIONS, "edition", listEditions[iAudience] ).program + "' style='display:inline-block;height:16px;width:16px;margin-right:2px;color:white;text-align:center;padding-left:1px;padding-right:1px;'>" + findByProperty( EDITIONS, "edition", listEditions[iAudience] ).code + "</div>";
	}

	if ( htmlAudienceSwatches )
	    htmlAudienceSwatches = "<div style='margin-bottom:.25em;'>" + htmlAudienceSwatches + "</div>";

	var nVisible = 0;
	for ( var iEdition = 0; iEdition < EDITIONS.length; iEdition++ )
	    if ( isAccountVisible( iEdition ) )
		nVisible++;

	if ( nVisible <= 1 )		// don't bother showing swatches if we are ONLY interested in one edition
	    htmlAudienceSwatches = "";
    }

    var htmlEvent = "<div " + (editionEnum !== undefined ? "foreign" : "") + " class='primary " + strClasses + "' " + strTooltip + strSection + " data-timestamp=" + timestamp + " data-outingid=" + outingID + jsOnClick + ">" + htmlLegendSwatch + "<div class='event" + (isCancelled(outing) ? " cancelled" : "") + "'><span class='summary-labels'>" + htmlAudienceSwatches + htmlOuting + "</span><span class='event-name'>" + getOutingName( outing ) + "</span>" + strTime + strLocation + "</div></div>";

    // insert the payload into the cell, sorted by timestamp
    var listTimestamps = [];
    var mapPayloads = {};

    // initialize the list with the payload we're about to insert
    listTimestamps.push( timestamp + "." + getEditionOrder( outing.editionenum ) + "." + generateOutingSortKey( outing ) );
    mapPayloads[outing.outingid] = htmlEvent;

    // append any existing events
    cal.find( "td[data-date=" + strDate + "] div[data-outingid]" ).each( function() {
	const payloadOuting = getOuting( $(this).attr( "data-outingid" ) );
	var strEventSortKey = $(this).attr( "data-timestamp" ) + "." + getEditionOrder( payloadOuting.editionenum ) + "." + generateOutingSortKey( payloadOuting );

	if ( $.inArray( strEventSortKey, listTimestamps ) == -1 )
	    listTimestamps.push( strEventSortKey );

	mapPayloads[payloadOuting.outingid] = $(this).clone().wrap("<p>").parent().html();
    });

    listTimestamps.sort();
    reconstructCalendarCells( cal, strDate, mapPayloads, listTimestamps );
}

function reconstructCalendarCells( cal, strDate, mapPayloads, listKeys )
{
    // reconstruct the calendar cell's contents
    cal.find( "td[data-date=" + strDate + "] div[data-outingid]").remove();
    for ( var iEvent = 0; iEvent < listKeys.length; iEvent++ )
    {
	var strKey = listKeys[iEvent];
	var payloadOutingID = -1;
	if ( strKey.match( /-(\d+)$/ ) )		// see generateOutingSortKey
	    payloadOutingID = parseInt( RegExp.$1 );

	if ( payloadOutingID == -1 )
	{
	    console_trace( "wowot! could not parse outing ID from '" + strKey + "'" );
	    continue;
	}

	var htmlEvent = mapPayloads[payloadOutingID];
	// if the outing starts BEFORE the first day of the calendar, then there
	// will be no place to stuff it, so we have to create an invisible day
	if ( cal.find( "td[data-date=" + strDate + "]").length == 0 )
	{
	    // note we prepend this (not append) because the ajaxGetAccountOutings depends on
	    // the tr:last-child and td:last-child being the actual last date
	    cal.find( "table.calendar tr:last-child" ).prepend( "<td date=" + strDate + " style='display:none;'></td>" );
	}

	cal.find( "td[data-date=" + strDate + "]").append( htmlEvent );
    }
}

function buildUpcomingEvents( bNavigate, callback )
{
    var div = $( "#list-upcoming-events" );
    div.empty();

    div.toggleClass( "readonly", true );

    var currentMonth = g_calOptions.currentMonth;
    if ( currentMonth == null )
    {
	currentMonth = new Date();	// get the whole date
        currentMonth = new Date( currentMonth.getFullYear(), currentMonth.getMonth(), 1 );
    }

    // also see buildCalendarCalendarView() for the related accounts' events
    cal_buildCalendar( "#list-upcoming-events", currentMonth, function() {

	$( "#list-upcoming-events" ).before( "<div id='calendar-reattach' style='display:none;'></div>" );
	var cal = $( "#list-upcoming-events" ).detach();
	$( "#calendar-reattach" ).after( cal );
	$( "#calendar-reattach" ).remove();

	// get related accounts' events
	var strDateMin = $( "table.calendar tbody tr:first-child td:first-child" ).attr( "data-date" );
	var strDateMax = $( "table.calendar tbody tr:last-child td:last-child" ).attr( "data-date" );

	var dateMin = addDaysToTimestamp( parseISO( strDateMin ), -21 );	// three weeks before first day (this'll cover all but a WJ)
	var dateMax = addDaysToTimestamp( parseISO( strDateMax ), 1 );      	// effectively midnight of last day

	// see if there's any other calendars to inject, and then extend all the multi-day events
	ajaxGetAccountOutings( dateMin, dateMax, false, function() { buildCalendarCalendarView( "#list-upcoming-events", callback ); } );

	$( "table.calendar td[data-date] div[data-outingid]" ).bind( "click", function( evt ) {
	    var outingID = $(this).attr( "data-outingid" );
	    doClickOnScheduleEvent( evt, outingID );
	} );
    } );

    $( "#list-upcoming-events" ).show();

    if ( bNavigate )
    {
	resetUpPoint( "#upcoming" );
	doResize();
    }
}

function doClickOnScheduleEvent( evt, outingID )
{
    // javascript event stuff to stop bubbling
    evt = evt || window.event;
    evt.stopPropagation();

    // if this is a deadline reminder, then we want to actually reference the source event
    if ( outingID.match( /(\d+)-(\d+)-(\d+)-deadline/ ) )
    {
	let outingID_deadline = parseInt( RegExp.$3 );
	outingID_deadline = outingID_deadline - ( outingID_deadline > IMPLICIT_EVENT_OFFSETS.submission ? IMPLICIT_EVENT_OFFSETS.submission : IMPLICIT_EVENT_OFFSETS.deadline );	// subtract the implicit event offset
	const outingID_stub = RegExp.$1 + "-" + RegExp.$2 + "-" + outingID_deadline;

	// see if any outingID's in the calendar match this stub
	$( "table.calendar td[data-date] div[data-outingid]" ).each( function() {
	    const candidateOutingID = $(this).attr( "data-outingid" );
	    if ( candidateOutingID != outingID && candidateOutingID.includes( outingID_stub ) )
	    {
		outingID = candidateOutingID;
		return false;	// break
	    }
	});
    }

    window.open( buildEventLink( outingID ) );
}

function ajaxGetAccountOutings( dateMin, dateMax, isPrefetch, callback )
{
    if ( g_listVisibleSchedules.length == 0 )
	initializeVisibleSchedules();

    if ( isNaN( dateMin ) || isNaN( dateMax ) )
    {
	console_trace( "dateMin = " + dateMin + ", dateMax = " + dateMax, true );
	openLightBox( { text: "Unable to fetch other schedules' events.  Please refresh (e.g., press F5) and try again.", canClose: true, size: "w-xwide" } );
	return;		// nothing to do
    }

    var strSelect = "eventstart_timestamp.ge." + dateMin + " AND eventstart_timestamp.lt." + dateMax + " AND visibility.eq." + VISIBILITY.everyone.id;
    var strKeys = g_listVisibleSchedules.join(",");
    if ( g_listAccounts.length > 1 && ! strKeys )
    {
        openLightBox( { text: "You do not have any accounts selected.", size: "w-wide", canClose: true } );
	return;
    }

    // just a youth account?  Make sure we have a key!
    if ( ! strKeys && g_listAccounts.length == 1 )
        strKeys = g_listAccounts[0].accountkey;

    if ( g_isFetchingOutings )
    {
	//console_log( "fetch already in progress" );
	//return;
    }

    if ( g_mapCachedOutings[dateMin] === undefined )
    {
	if ( ! isPrefetch )
	    openLightBox( { text: "Fetching events...", timeout: 0 } );		// needs to be programatically closed

	g_isFetchingOutings = true;
	myAjax( {
	    url: BADGES_TABLES,
	    data: "uid=-1&accountid=0&worksheet=Outings&keys=" + strKeys + "&select=" + strSelect + "&request=" + encodeURIComponent( window.location.pathname ),
	    timeout: AJAX_TIMEOUT,
	    error: function( request, textStatus, errorThrown ) {
		g_isFetchingOutings = false;
		closeLightBox();
		console_error( "Error fetching other account events: " + textStatus + " (" + errorThrown + ")", true );
		openLightBox( { text: "Error fetching schedules", canClose: true, trace: false } );
	    },
	    success: function( data ) {
		g_mapCachedOutings[dateMin] = data;
		processAccountOutings( data, isPrefetch, callback );
		closeLightBox();
	    }
	} );
    }
    else if ( ! isPrefetch )
	processAccountOutings( g_mapCachedOutings[dateMin], isPrefetch, callback );
    else
    	; // if I'm doing a prefetch, and I already have this month's data, do nothing
}

function isDeadlinePast( timestampDeadline, timestampTest )
{
    if ( timestampTest === undefined )
	timestampTest = getNow();

    return timestampDeadline > 0 && timestampTest > new Date( timestampDeadline ).getEndOfDayTimestamp();
}

function createDeadline( outing )
{
    if ( isImplicit( outing ) )
        return null;     // don't create deadlines for deadlines

    if ( isCancelled( outing ) )
        return null;

    var timestampSignup = getSignupTimestamp( outing );
    if ( timestampSignup > 0 && ! isDeadlinePast( timestampSignup ) && timestampSignup < outing.date )
    {
	var strEdition = getEditionInfo( outing.editionenum ).edition;
        var jsonAccount = findByProperty( g_listAccounts, "edition", strEdition );			// note that this will just find ANY of your accounts of this edition, but that's okay, because we just want the reminder event labelkey
	var reminderID = findByProperty( jsonAccount.labels, "labelkey", "reminder" );
	var outingID = parseInt( outing.outingid ) + IMPLICIT_EVENT_OFFSETS.deadline;			// derived from original
	var outingKey = outing.editionenum + "-" + outing.accountid + "-" + outingID + "-deadline";	// create a fake key for this event (which doesn't exist on the server)
	var strName = "Signup Deadline: " + outing.displayname;  // derived from original
	var accountKey = outing.accountkey;
	var label = findByProperty( findByProperty( g_listAccounts, "accountid", outing.accountid ).labels, "labelkey", "reminder" );
        var outingDeadline = {
	    outingkey: outingKey,
	    accountkey: accountKey,
	    editionenum: outing.editionenum,
	    accountid: outing.accountid,
            outingid: outingID,
            date: getSignupTimestamp( outing ),           // derived from original
            displayname: strName,
            notes: "",
            cost: { participant: 0, deposit: 0, program: 0 },
            labels: [{ id: reminderID, labelkey: "reminder", count: 1, rollup: 3, image: label.image }],        // make it a reminder
            visibility: outing.visibility,          // derived from original
            closed: false,
            status: OUTING_STATUS.active.id,
            youth: [],
            links: [],
	    isallday: true,
            timestampsignup: 0,
            attachments: [],
            youthsignups: [],
            youthpayments: [],
            youthdeposits: [],
            signupnotifications: [],

            isimplicit: true
        };

        return outingDeadline;
    }

    return null;
}

function processAccountOutings( data, isPrefetch, callback )
{
    var accountKey = "";
    var outing = null;

    g_isFetchingOutings = false;
    if ( ! isPrefetch )
    {
	g_mapAccountOutings = {};

	for ( var outingID in data["Outings"] )
	{
	    outing = data["Outings"][outingID];
	    accountKey = outing.accountkey;
	    if ( g_mapAccountOutings[accountKey] === undefined )
		g_mapAccountOutings[accountKey] = {};

	    g_mapAccountOutings[accountKey][outingID] = cloneEvent( outing, true );
	}
    }

    // now see if any deadlines need to be created
    for ( var outingID2 in data["Outings"] )
    {
	outing = data["Outings"][outingID2];
	var outingDeadline = createDeadline( outing );
	if ( outingDeadline )
	{
	    accountKey = outingDeadline.accountkey;
	    if ( g_mapAccountOutings[accountKey] === undefined )
		g_mapAccountOutings[accountKey] = {};

	    g_mapAccountOutings[accountKey][outingID2] = cloneEvent( outing, true );
	    g_mapAccountOutings[accountKey][outingDeadline.outingkey] = cloneEvent( outingDeadline, true );
	}
    }

    if ( callback )
	callback();		// eslint-disable-line callback-return
}

// called by buildUpcomingEvents and the callback to get related outings
function buildCalendarCalendarView( selector, callback )
{
    var listOutingIDs = [];
    var mapOutings = {};
    var outingID = -1;
    var outing = null;

    $( selector ).before( "<div id='calendar-reattach' style='display:none;'></div>" );
    var cal = $( selector ).detach(); 		// note, this detaching and reattaching (see .after() below) removes all of the bound click handlers

    // Now insert all the events from the other schedules
    for ( var iAccount = 0; iAccount < g_listVisibleSchedules.length; iAccount++ )
    {
	var accountKey = g_listVisibleSchedules[iAccount];
	listOutingIDs = sortOutingsByDate( g_mapAccountOutings[accountKey] );
	$.each( listOutingIDs, function( iOuting2, outingID2 ) {
	    outing = g_mapAccountOutings[accountKey][outingID2];

	    insertEventIntoCalendar( cal, outing, outing.editionenum );
	    mapOutings[outingID2] = outing;
	});
    }

    // cull the duplicate subscriptions
    cullSubscribedEvents( cal );

    $( "#calendar-reattach" ).after( cal );
    $( "#calendar-reattach" ).remove();

    // extend the multi-day events
    listOutingIDs = sortOutingsByDate( mapOutings );
    for ( var iOuting = listOutingIDs.length - 1; iOuting >= 0; iOuting-- )
    {
	outingID = listOutingIDs[iOuting];
	outing = mapOutings[outingID];

	var timestamp = outing.date;
	if ( outing.isallday )
	    timestamp = getStartOfDayTimestamp( outing.date );

	var htmlEvent = $( "table.calendar div[data-outingid=" + outingID + "]" ).clone().wrap("<p/>").parent().html();
	var nCount = getOutingNights( outing );
	for ( var iNight = 0; iNight < nCount; iNight++ )
	{
	    timestamp = addDaysToTimestamp( timestamp, 1 );
	    var strDate = formatDate( timestamp, "yyyy-mm-dd" );	// MUST be ISO format
	    if ( isReminder( outing ) )
		$( "table.calendar td[data-date=" + strDate + "] span.day" ).after( htmlEvent );
	    else
	    {
		// duplicate the outing original description (i.e., from the first day)
		// then strip out all the extra stuff we don't want to see (e.g., location, labels)
		var el = $( htmlEvent );		// make a copy of the event's entry in the calendar
		el.removeClass( "primary" );
		el.find( "span.summary-labels" ).remove();
		el.find( "span.location" ).remove();

		var elLabel = el.find( "span.event-name" );
		elLabel.html( "&nbsp;&rArr; <span style='font-style:italic;'>" + elLabel.html() + " (con't)</span>" );

		// where should we stuff this sucker?
		//$( "table.calendar td[data-date=" + strDate + "] span.day" ).closest( "td" ).append( el );
		var isPrepended = false;
		$( "table.calendar td[data-date=" + strDate + "] span.day" ).closest( "td" ).find( "div[data-outingid]" ).each( function() {
		    var otherOutingID = $(this).attr( "data-outingid" );
		    var otherOuting = getOuting( otherOutingID );

		    if ( outing.date < otherOuting.date )
		        isPrepended = true;		// chronologically before, so prepend

		    else if ( outing.date > otherOuting.date )
		        ;				// chronologically after, so append

		    else 				// chronologically identical (e.g., two all-day events)
		    {
			if ( ! isReminder( outing ) && isReminder( otherOuting ) )
			    ;				// always append non-reminder after reminders
			else
			    isPrepended = outing.outingid < otherOuting.outingid;	// default to outing ID as the sort order
		    }

		    if ( isPrepended )
		    {
			$(this).before( el );
			return true;		// break;
		    }
		});

		if ( ! isPrepended )
		    $( "table.calendar td[data-date=" + strDate + "] span.day" ).closest( "td" ).append( el );
	    }
	}
    }

    if ( typeof g_strTag != "undefined" && g_strTag )
    {
	// hide everything
	$( "table.calendar td[data-date] div.primary" ).hide();
	$.each( EDITIONS, function( iEdition, jsonEdition ) {
	    var isVisible = getLocalStorage( "visible-" + jsonEdition.edition, "" ) != "false";
	    if ( isVisible )
		$( "table.calendar td[data-date] div.audience-" + jsonEdition.edition + ".primary" ).show();
	});
    }

    // Add a click handler for all the new just-added events
    // The function we're in is theory just for related events, but the detach/after process removes all bound click handlers.   So we have to re-add them all anyway.
    $( "table.calendar td[data-date] div[data-outingid]" ).bind( "click", function( evt ) {
	outingID = $(this).attr( "data-outingid" );
	doClickOnScheduleEvent( evt, outingID );
    } );

    // prefetch the next month's data
    //window.setTimeout( function() { prefetchCalendarOutings( new Date( g_calOptions.currentMonth.getFullYear(), g_calOptions.currentMonth.getMonth() + 1, 1 ) ); }, 2000 );
    prefetchCalendarOutings( new Date( g_calOptions.currentMonth.getFullYear(), g_calOptions.currentMonth.getMonth() + 1, 1 ) );

    if ( callback )
        callback();		// eslint-disable-line callback-return

    doResize();
}

// check if every visible account has events in each of the cached dates
// if no events can be found in the cache, then this function returns false
// under the assumption that nothing was fetched for this (new?) account
//
// Note: this will give a false negative if the account (e.g., Committee) simply
// hasn't got any events for a particular month.  But then, it'll be the same
// as blindly scrubbing the cache, which is what we used to do
function needToScrubCachedOutings()
{
    for ( var iAccount = 0; iAccount < g_listVisibleSchedules.length; iAccount++ )
    {
	var strKey = g_listVisibleSchedules[iAccount];
	var reAccountMatch = new RegExp( strKey.replace( /^([\d]+-[\d]+-).+/, "$1" ) );		// just keep the edition enum and accountid

	var mapHasAnyEvents = {};

	// for each date, check if this account has ANY outings
	for ( var dateMin in g_mapCachedOutings )
	{
	    mapHasAnyEvents[dateMin] = false;
	    var jsonCachedOutings = g_mapCachedOutings[dateMin];
	    if ( jsonCachedOutings.Outings !== undefined )
	    {
		for ( var outingID in jsonCachedOutings.Outings )
		{
		    if ( outingID.match( reAccountMatch ) )
		    {
			mapHasAnyEvents[dateMin] = true;
			break;
		    }
		}
	    }
	}

	// now check that ALL dates have one or more outings
	var isMissingEvents = false;
	for ( var dateMin2 in g_mapCachedOutings )
	    if ( ! mapHasAnyEvents[dateMin2] )
	        isMissingEvents = true;

	if ( isMissingEvents )
	    return true;
    }

    return false;
}

function initializeVisibleSchedules()
{
    $.each( g_listAccounts, function( i, jsonAccount ) {
	if ( g_listAccounts.length == 1 || isAccountVisible( i ) )
	    g_listVisibleSchedules.push( jsonAccount.accountkey );
    });

    if ( needToScrubCachedOutings() )
	g_mapCachedOutings = {};
}

function myAjax( options )
{
    var ios6bug = "ios6bug=" + getNow();

    if ( options.data === undefined )
	options.data = ios6bug;
    else
	options.data = options.data + "&" + ios6bug;

    jQuery.ajax( options );
}

function buildEventReportRow( listLabels, useDataURI, bShowRollup, addTooltips )
{
    var htmlOuting = "";

    if ( listLabels == null )
	listLabels = [];

    var listSortedLabels = [];
    $.each( listLabels, function( i, label ) {
	listSortedLabels.push( label.labelkey );
    });

    // build up the images of the listLabels and attributes
    $.each( listSortedLabels, function( iLabel, labelKey ) {
	$.each( listLabels, function( iOuting, outingLabel ) {
	    if ( outingLabel.labelkey == labelKey )
	    {
		var label = outingLabel.rollup !== undefined ? outingLabel : g_dbTables['Labels'][outingLabel.labelkey];

		var htmlAttributes = "";
		if ( outingLabel.attributes !== undefined  )
		{
		    $.each( outingLabel.attributes, function( iAttribute, attributeID ) {
			var attribute = isNaN( attributeID ) ? attributeID : g_dbTables['Attributes'][attributeID];
			if ( attribute !== undefined )
			{
			    var strTitle = addTooltips ? ("title='" + attribute.name + "'") : "";
			    if ( attribute.image !== undefined && attribute.image.length > 0 )
				htmlAttributes += "<img " + strTitle + " src='" + (useDataURI ? attribute.image : ("/" + STR_DIRECTORY + "/edition-images/" + label.labelkey + "." + attribute.attributekey + ".gif")) + "'/>";
			}
			else
			    console_warn( "skipping unrecognized attribute " + attributeID );
		    });
		}

		var image = "";
		if ( label.image !== undefined && label.image.length > 0 )
		    image = "<img title='" + label.namesingular + "' src='" + (useDataURI ? label.image : ("/" + STR_DIRECTORY + "/edition-images/" + label.labelkey + ".gif")) + "'/>";

		var htmlLabel = image + htmlAttributes;
		if ( label.rollup == ROLLUP_DAYS && outingLabel.count == 1 )
		    ;  // don't show "x1d" for single days
		else if ( label.rollup != ROLLUP_NONE && bShowRollup )
		    htmlLabel += "<span style='font-weight:normal;'>&times;</span>" + formatRollupCount( outingLabel.count, getRollupUnit( label.rollup ) );

		if ( htmlLabel.length > 0 )
		    htmlOuting += "<span labelkey=" + outingLabel.labelkey + " style='margin-right:10px;'>" + htmlLabel + "</span>";
	    }
	});
    });

    return htmlOuting;
}

function getOutingMemberLabels( outing )
{
    var listLabels = null;

    if ( outing !== undefined )
	listLabels = uniquifyList( outing.labels );

    return listLabels;
}

function getAccountColor( editionEnum, accountID )
{
    if ( isSingleEdition( g_listAccounts ) )
    {
        if ( typeof accountID == "string" && accountID.match( /^\d+-(\d+)-.+/ ) )
	    accountID = RegExp.$1;

	var iMatch = 0;
	var re = new RegExp( "^\\d+-" + accountID + "-.+" );
	$.each( g_listAccounts, function( i, jsonAccount ) {
	    if ( re.test( jsonAccount.accountkey ) )
	    {
		iMatch = i;
		return true;	// break;
	    }
	});

	if ( iMatch == g_listAccounts.length - 1 )		// treat the last one as special
	    return "#eb0a2c";
	else
	{
	    var palette = [ "#88aa33", "#ddbb00", "#dd99cc", "#bb7777", "#559977", "#6688bb", "#cc8800", "Gray", "#77bbbb", "#995588" ];
	    return palette[iMatch % palette.length];
	}
    }
    else
	return getEditionInfo( editionEnum ).color;
}

function getAccountNameByKey( strKey )
{
    var jsonAccount = findByProperty( g_listAccounts, "accountkey", strKey );
    return getAccountName( jsonAccount );
}

function getOuting( outingKeyOrID )
{
    for ( var accountKey in g_mapAccountOutings )
    {
	var outing = g_mapAccountOutings[accountKey][outingKeyOrID];		// is outingKeyOrID a key, if so, we can directly look it up
	if ( outing !== undefined )
	    return outing;

	for ( var strKey in g_mapAccountOutings[accountKey] )
	    if ( outingKeyOrID == parseInt( strKey.replace( /^\d+-\d+-(\d+)-.+/, "$1" ) ) )		// extract the outing ID from the key
		return g_mapAccountOutings[accountKey][strKey];
    }

    return null;
}

function cullSubscribedEvents( cal )
{
    var outingID = -1;
    var outing = null;
    var strName = null;
    var i = 0;

    var mapSubscriptionEvents = {};
    cal.find( "div[data-outingid].primary" ).each( function() {
	outingID = $(this).attr( "data-outingid" );
	outing = getOuting( outingID );

	// if this outing is a subscription to another event, then we can potentially remove it
	if ( outing != null )
	{
	    if ( isOutingSubscription( outing ) && inferOutingKeyEdition( outing.subscription.outingkey ) != -1 )
	    {
		if ( mapSubscriptionEvents[outing.subscription.outingkey] === undefined )
		    mapSubscriptionEvents[outing.subscription.outingkey] = [];

		// add to the list of subscribed events... but make sure my account is always at the front of the list
		if ( outing.editionenum === undefined )
		    mapSubscriptionEvents[outing.subscription.outingkey].unshift( outingID );			// prepend
		else
		    mapSubscriptionEvents[outing.subscription.outingkey].push( outingID );			// append
	    }
	}
	else
	    console_trace( "no outing for " + outingID );
    } );

    for ( var strSubscriptionKey2 in mapSubscriptionEvents )
    {
	outing = getOuting( strSubscriptionKey2 );
	if ( outing == null ) // subscription to a non-visible account
	    continue;

	outing.subscriptions = mapSubscriptionEvents[strSubscriptionKey2];	// copy this list in, so that we can use during re-shares
    }

    // now we have to pick a source event
    for ( var strSubscriptionKey in mapSubscriptionEvents )
    {
	if ( strSubscriptionKey.match( /(\d+)-(\d+)-(\d+).+/ ) )
	{
	    var otherEditionEnum = RegExp.$1;
	    //var otherAccountID = RegExp.$2;
	    var otherOutingID = RegExp.$3;

	    // the source event exists in my account
	    if ( otherEditionEnum == EDITION_ENUM && getOuting( otherOutingID ) != null )
	    {
		for ( i = 0; i < mapSubscriptionEvents[strSubscriptionKey].length; i++ )
		{
		    outingID = mapSubscriptionEvents[strSubscriptionKey][i];
		    outing = getOuting( outingID );
		    if ( outing != null )
		    {
			strName = getAccountNameByKey( outing.accountkey );

			cal.find( "div[data-outingid=" + outingID + "]").remove();
			if ( outing.editionenum === undefined )
			    console_trace( "wowot! no editionenum in " + JSON.stringify( outing ) );

			if ( cal.find( "div[data-outingid=" + otherOutingID + "] div[data-editionenum=" + outing.editionenum + "][data-accountid=" + outing.accountid + "]").length == 0 )
			{
			    const strBgColor = getAccountColor( outing.editionenum, outing.accountid );
			    const strFgColor = getTextForegroundColor( strBgColor );
			    cal.find( "div[data-outingid=" + otherOutingID + "]" ).prepend( "<div data-editionenum=" + outing.editionenum + " data-accountid=" + outing.accountid + " q2 class='legend-swatch " + getEditionInfo( outing.editionenum ).edition + "' style='background-color:" + strBgColor + ";color:" + strFgColor + ";'>" + strName + "&nbsp;</div>" );
			}
			//else
			    //console_warn( "skipping event " + JSON.stringify( outing ) );
		    }
		    else
			console_warn( "wowot! couldn't find subscribed event " + outingID );
		}
	    }

	    // some other account is the source of the subscription
	    else
	    {
		// pick the first outing as the one we use as the base.  If we ourselves had subscribed to the the event, then we'll be at the top of the list
		if ( cal.find( "div[data-outingid=" + strSubscriptionKey + "]" ).length > 0 )
		{
		    if ( isCollaborationAccountOuting( mapSubscriptionEvents[strSubscriptionKey][0] ) )
			otherOutingID = strSubscriptionKey;
		    else
		    {
			otherOutingID = mapSubscriptionEvents[strSubscriptionKey].shift();				// remove the source event from the list...
			mapSubscriptionEvents[strSubscriptionKey].unshift( strSubscriptionKey );			// ... stuff it on to the front of the list
		    }
		}
		else
		{
		    otherOutingID = mapSubscriptionEvents[strSubscriptionKey].shift();					// remove the source event from the list...
		    mapSubscriptionEvents[strSubscriptionKey].unshift( strSubscriptionKey );				// ... stuff it on to the front of the list
		}
		//for ( i = 0; i < mapSubscriptionEvents[strSubscriptionKey].length; i++ )
		for ( i = mapSubscriptionEvents[strSubscriptionKey].length - 1; i >= 0; i-- )
		{
		    outingID = mapSubscriptionEvents[strSubscriptionKey][i];
		    outing = getOuting( outingID );
		    if ( outing != null )
		    {
			if ( outing.editionenum === undefined )
			{
			    console_trace( "not culling my own outing " + outingID );
			    continue;
			}

			var accountKey = outing.accountkey;
			if ( $.inArray( accountKey, g_listVisibleSchedules ) == -1 )		// is this a hidden account?
			    continue;

			strName = getAccountNameByKey( accountKey );

			if ( outing.editionenum === undefined )
			    console_trace( "wowot! no editionenum in " + JSON.stringify( outing ) );

			cal.find( "div[data-outingid=" + outingID + "]").remove();
			if ( cal.find( "div[data-outingid=" + otherOutingID + "] div[data-editionenum=" + outing.editionenum + "][data-accountid=" + outing.accountid + "]").length == 0 )
			{
			    const strBgColor = getAccountColor( outing.editionenum, outing.accountid );
			    const strFgColor = getTextForegroundColor( strBgColor );
			    cal.find( "div[data-outingid=" + otherOutingID + "]").prepend( "<div data-editionenum=" + outing.editionenum + " data-accountid=" + outing.accountid + " q3 class='legend-swatch " + getEditionInfo( outing.editionenum ).edition + "' style='background-color:" + strBgColor + ";color:" + strFgColor + ";'>" + strName + "&nbsp;</div>" );
			}
			//else
			    //console_warn( "skipping event " + JSON.stringify( outing ) );
		    }
		}
	    }
	}
	else
	    console_warn( "found malformed subscription key '" + strSubscriptionKey + "'" );
    }
}

function inferKeyType( strKey )			/* eslint-disable-line no-unused-vars */
{
console_trace( "deprecated?" );
    if ( strKey != null && strKey.match( /^(\d+)-(\d+)-(.{20})$/ ) )	// we need RegExp.$1
	return RegExp.$1;

    return -1;
}

function isCollaborationAccountOuting( outingID )
{
    return isNaN( outingID );
}

function doManualSync_final()	/* eslint-disable-line no-unused-vars */
{
    g_mapCachedOutings = {};
    buildUpcomingEvents( false );
}

function prefetchCalendarOutings( currentMonth )
{
    // do a dummy fetch of the calendar entries so we don't have an apparent fetch when we first switch to Calendar View
    if ( currentMonth == null )
    {
	currentMonth = new Date();	// get the whole date
        currentMonth = new Date( currentMonth.getFullYear(), currentMonth.getMonth(), 1 );
    }

    var oldCurrentMonth = g_calOptions.currentMonth;
    var oldCallback = g_calOptions.callback;
    cal_buildCalendar( "#calendar-placeholder", currentMonth, function() {
	g_calOptions.currentMonth = oldCurrentMonth;
	g_calOptions.callback = oldCallback;
	// get collab account's event
	var strDateMin = $( "#calendar-placeholder table.calendar tbody tr:first-child td:first-child" ).attr( "data-date" );
	var strDateMax = $( "#calendar-placeholder table.calendar tbody tr:last-child td:last-child" ).attr( "data-date" );

	var dateMin = addDaysToTimestamp( parseISO( strDateMin ), -7 );     // one week before first day (technically, we could have 10 day events preceeding this month)
	var dateMax = addDaysToTimestamp( parseISO( strDateMax ), 1 );      // effectively midnight of last day

	// see if there's any other calendars to inject, and then extend all the multi-day events
	ajaxGetAccountOutings( dateMin, dateMax, true, null );
    });
}

function isSingleEdition( listAccounts )
{
    var setEditions = {};
    $.each( listAccounts, function( i, jsonAccount ) {
        setEditions[jsonAccount.edition] = 1;
    });

    return size( setEditions ) == 1;
}

function getAccountName( jsonAccount )
{
    if ( isSingleEdition( g_listAccounts ) )
	return jsonAccount.subgroup ? jsonAccount.subgroup : getEditionInfo( jsonAccount.edition ).program;
    else
	return getEditionInfo( jsonAccount.edition ).program + (jsonAccount.subgroup ? (" (" + jsonAccount.subgroup + ")") : "");
}

/**
 * Sorts the accounts, first by edition, and then by name
 */
function sortAccounts()	// eslint-disable-line no-unused-vars
{
    var listAccounts = [];
    var strMungedLabel = "";

    // first, group all the accounts by edition
    var mapListAccounts = {};
    $.each( g_listAccounts, function( i, jsonAccount ) {
	listAccounts = mapListAccounts[jsonAccount.edition];
	if ( listAccounts === undefined )
	{
	    listAccounts = [];
	    mapListAccounts[jsonAccount.edition] = listAccounts;
	}
	listAccounts.push( jsonAccount );
    });

    // first, sort each list by subgroup, making sure it handles duplicate names
    for ( var strEdition in mapListAccounts )
    {
        listAccounts = mapListAccounts[strEdition];

	if ( listAccounts.length > 1 )		// any point in sorting?
	{
	    var listSortedLabels = [];
	    var mapAccountsByLabel = {};

	    for ( var iAccount = 0; iAccount < listAccounts.length; iAccount++ )
	    {
		strMungedLabel = getAccountName( listAccounts[iAccount] ).toLowerCase() + ":" + iAccount;		// ensure each label is unique by appending the index, e.g., "Cubs:2"
		listSortedLabels.push( strMungedLabel );      // append this date
		mapAccountsByLabel[strMungedLabel] = iAccount;
	    }

	    listSortedLabels.sort();

	    var listSortedAccounts = [];
	    for ( var iLabel = 0; iLabel < listSortedLabels.length; iLabel++ )
	    {
		strMungedLabel = listSortedLabels[iLabel];
		var iLabelAccount = mapAccountsByLabel[strMungedLabel];
		listSortedAccounts.push( listAccounts[iLabelAccount] );
	    }

	    mapListAccounts[strEdition] = listSortedAccounts;		// save the sorted list	back into the orginal map
	}
    }

    listAccounts = [];
    $.each( EDITIONS, function( iEdition, jsonEdition ) {
	var strEdition2 = jsonEdition.edition;
	if ( mapListAccounts[strEdition2] !== undefined )
	    listAccounts = listAccounts.concat( mapListAccounts[strEdition2] );
    });

    return listAccounts;
}
