Monthly Bookings Report

I’m a total noob trying to create a backend function to extract bookings data, given that Wix doesn’t allow me to do this with its built-in functions (which I would prefer). I would like to do the following, and would appreciate anyone who could give me advice on any part of this, or even someone who has already done something like this.

  1. A back-end function that can be accessed from the Wix Dashboard by an admin who has access to our site. Ability to control access to this function via site privileges would be useful.
  2. Extract the bookings data for all services for a specific time range (e.g., the previous month).
  3. Ability to filter the query based on a single field in the bookings data, e.g., Payment Details == “Pricing Plan X”. Bonus to also filter out no shows.
  4. I expect the total extract to be more than 1000 elements before filtering and fewer than 1000 elements after filtering.
  5. Extract a specific set of fields for each booking item, including the service date, the service location, attendance, and the email address of the person who made the booking.
    Bonus points to filter out duplicates (email address and date are the same for two bookings).
  6. Bonus points if the extract also includes fields from the contact data for the person, but I can live without that, as Wix supports export of contact info and I can use Excel to cross reference them.
  7. Output the data as a CSV.

My goal is to run a monthly report of past bookings for a certain subset of members (who will all have used a specific pricing plan to pay for the booking) in Excel. The biggest problem I have trying to do this using the built-in Bookings App is that the export feature doesn’t include the Service Location. Other than that, I could work around everything (e.g., multiple exports to get around the limit of 1000, using Excel to do all the filtering).

Thanks for any help!

I realize that my post is way more than just one question, on the off chance that someone has already done something similar. But on the strong likelihood than nobody has, I will break this up into individual questions. My first question is, can someone help me create a backend Hello World app that can be accessed from the Wix Dashboard? Once I have that, I can think about the meat of it, like the bookings query and the export to CSV.

Ok, I found the answer to my first question. It’s called “Dashboard Pages”.

I also discovered a Velo example called Bookings List, which gives me a good start on what I’m trying.

It includes backend code that does the actual query. Unfortunately, even through I tried to copy this exactly, I haven’t yet gotten it to work. I’ve found that Velo is difficult to debug, especially backend code. Any tips on how to get an actual debugger that I can use with single step, breakpoints, etc., with Velo, especially with backend, async functions, would be helpful at this point.

Ok, I have figured this all out. Shocking that it only took me a couple of weeks. I haven’t written any code in at least a decade, and my knowledge of JavaScript was very elementary (knowing nothing about async processes with promises, backend functions, elaborate mapped objects, etc.) Plus I really knew nothing about the Velo environment. I never figured out how to run in a debugger, either for front-end code or back-end code, so everything was done by making tiny code changes and using console.log. If anybody could help me figure out how to run a modern debugger on my code, for the next project or for enhancements to this one, I would be most grateful.

In the meantime, in case it would be helpful to someone else out there, I will describe my problem here, and then post separately pieces of my solution. Certainly, finding other people’s code fragments online helped me figure this all out, piece by piece. Understanding my requirements will help understand why I did what I did, whether it might be relevant to you, and how to modify it for your own needs. I know that, as a noob, I found some online solutions to be hard to make use of because of lack of presumed context.

Our organization uses Wix for our website. We have hundreds of members and dozens of classes every week, across several locations. Our members purchase one of our pricing plans and use them to order bookings for any of our classes. Standard Wix stuff.

Where it gets complicated is that our organization partners with more than one other organization, such that the members of the partner organization get a free or reduced price membership in our organization. A nice win-win, but one of the requirements is that we provide them with a monthly report on the classes attended by our co-members during the previous month.

I’ve tried using existing Wix export features (limited ability in Wix to export contacts and booking information), load the data in Excel, and do all the correlation, summation, and formatting there. But Wix’s bookings export is just too limited. You can only export 1000 bookings at a time, and we definitely get more than 1000 bookings in any given month. Plus the export doesn’t include all of the fields we need for our monthly report, such as the class location.

Hence this project. I need to extract all of the bookings data for the previous month, filter it to just our partner co-members, and then put the bookings data in separate Excel files in the format defined by the partner organization. Some organizations want data on each attendance during the month, and some want only a count. Also, generally multiple classes on the same day should be counted only as a single visit.

Oh, and some partner organizations have multiple plans under which our partnership is tracked, for different groups of their own members.

Here’s what we do in our Wix system to track usage by partner co-members:

  • We have created custom labels, one for each combination of partner organization and partner plan. Each co-member is tagged with one of those labels.
  • We created a custom field for our members for the Partner ID, the unique identifier assigned by the partner organization to the member.
  • We created a Partner Membership pricing plan that is only available to co-members of any organization.

That’s it. I will post some code from my solution in additional replies to this thread. I hope someone finds it helpful. Please reply with questions or if you think I missed something.

1 Like

The first thing I learned is that bookings data is retrieved in a backend process. This means the code has to run on the Wix server, not on the user’s computer. I created a backend module in which to put my bookings retrieval function. That JS function is exported and called by the front-end function that our admin uses to perform the monthly partner reports.

What did I learn?

  1. The information about each booking is actually spread out over several databases. Attendance data (for filtering out no-shows) is a separate query once you have a booking. Information about the member who made the booking is yet another query. This function will need to make all those subqueries for related info, so that I can return a flattened array as a result, where each array element, representing one booking, is an object which consists of attributes that are either strings or date objects. It should be easy for someone to customize this to add additional fields in the returned bookings.
  2. You have to go through the bookings data page by page. You can’t get it all at once. The Velo documentation says you can get up to 1000 bookings in a single query, but I found that nothing more than 100 actually worked.
  3. In any case, I went with a page size of 20. This backend query is really slow. I found it takes about 8 seconds for each page query. That of course is based on whatever server we are using, the size and complexity of our database, etc. If the query is too big, I found that you get server timeouts (error 504). That’s why I made the page size into a parameter, so that it could easily be tuned if necessary.
  4. I’ve also seen occasional error 429, too many http requests. I suspect this may be caused by retrieving too many bookings too quickly, but I’m not sure. In any case, I implemented a configurable delay between calls to this module in my front-end process, if this becomes a problem.
  5. I could never get the currentMember API to work, so that I could check for an authorized user and prevent just anyone from accessing our partner information (which may be covered by NDA with our partner organizations.) I believe that is because those APIs don’t work properly when you are in Preview mode. Unfortunately, I refuse to publish any code that I can’t test first. To work around this, this function accepts a password parameter, and compares the password with one stored in Wix’s Secrets manager. Even more secure, as I can restrict access to only specific people with the password.

Here is the code in my backend module. You can create a new backend module in the Wix Editor while you are in Dev Mode by clicking on the {} icon on the left menu on the screen.

// *****************************************************************************************
// This module defines the functions to provide backend functionality for this Wix site
// *****************************************************************************************
import { bookings } from "wix-bookings-backend";
import { attendance } from 'wix-bookings.v2';
import wixUsers from 'wix-users-backend';
import wixCrmBackend from 'wix-crm-backend';
import { contacts } from 'wix-crm-backend';
import wixSecretsBackend from 'wix-secrets-backend';

// Currently, the functions in here bypass authorization checking
// Not a good practice, but have not gotten this working without this bypass
let options = {
    suppressAuth: true
}

// *****************************************************************************************
// getBookingsPage
// *****************************************************************************************
// This function should only be called from a dashboard page.
// It does not check for user authorization,
//    and hence could be a security issue is called from a page that is accessible to a non-Admin.
// It allows the retrieval of bookings data from the current site.
// Only bookings marked as "CONFIRMED" will be returned.
// Bookings will be sorted in ascending order by session date and time.
// It retrieves one page of bookings data at a time,
//   and must be called multiple times to get an enire dataset.
// Parameters:
//    dateStart is a Date object that defines the start date for the bookings data to be retrieved.
//    dateEnd is a Date object that defines the end date for the bookings data to be retrieved.
//    index is the starting bookings index for this page of bookings data.
//    count is the number of bookings objects to return for this page (i.e., page size).
//    password must match the partnerPassLogin stored in the Wix Secrets
// Why would you want to use a different page size?
//    I have tested with a value of 20.
//    Larger page sizes may cause a timeout error.
//    Smaller values may cause a "too many requests" error.
//    Your mileage may vary, based on your computer, the server used for your site,
//    the size of your database, or anything else.
// Bookings data will be returned that have a session date on startDate or later
//    and earlier than but not including endDate
// The return value is a Promise for an array of bookings objects as follows:
//    An empty array returned indicates no results, and can be used to detect the last page of results.
//    result[i].sessionTime (Date object representing the time of the session booked)
//    result[i].sessionLocation (String location of the session booked)
//    result[i].attendanceStatus (String one of "Booked", "Checked-In", "No-Show")
//    result[i].pricingPlan (String name of the pricing plan used to pay for the booking)
//    result[i].firstName (String first name of the contact who made the booking)
//    result[i].lastName (String last name of the contact who made the booking)
//    result[i].birthDate (Date object birthdate of the contact who made the booking)
//    result[i].partnerId (String custom field Partenr ID of the contact who made the booking)
//    result[i].labels (String custom labels of the contact, separated by semicolons)
// Following is sample code to use this function
//        for (let index = 0, moreBookings = true; moreBookings; index += 20) {
//            let results = await getBookingsPage(new Date('2023-10-01'), new Date('2023-11-01'), index, 20);
//            let pageSize = 0;
//            if (!results) {
//               moreBookings=false;
//            } else if (results.length == 0) {
//               moreBookings = false;
//            } else {
//               for (result of results) {
//                  /* Do something with each result */
//               }
//               moreBookings = true;
//            }
//        }
// *****************************************************************************************
export async function getBookingsPage(dateStart, dateEnd, index, count, password) {
    try {
        let partnerPassword = await wixSecretsBackend.getSecret("partnerPassLogin");
        if (partnerPassword == password) {
            /* Call the bookings API to get a page of bookings data */
            let bookingsPage = await bookings
                .queryBookings()
                .skip(index)
                .eq("status", "CONFIRMED")
                .ge("startTime", dateStart)
                .le("endTime", dateEnd)
                .ascending("startTime")
                .limit(count)
                .find(options);

            /* Initialize the set of results to be returned */
            let returnResults = [];

            /* loop through the returned bookings data */
            if (bookingsPage) {
                for (let thisBooking of bookingsPage.items) {
                    /* Default value for each field in returned result */
                    let thisResult = {
                        sessionTime: new Date(),
                        sessionLocation: "",
                        attendanceStatus: "Booked",
                        pricingPlan: "",
                        firstName: "",
                        lastName: "",
                        birthDate: null,
                        partnerId: "",
                        labels: ""
                    };

                    /* Copy these fields directly from query result to return result */
                    thisResult.sessionLocation = thisBooking.bookedEntity.location.businessLocation.name;
                    thisResult.sessionTime = thisBooking.bookedEntity.singleSession.start;

                    /* To get the attendance data, we have to do a separate query to the attendance module */
                    try {
                        let bookingAttendance = await attendance.queryAttendance().eq("bookingId", thisBooking._id).find();
                        if (bookingAttendance.length > 0) {
                            if (bookingAttendance.items[0].status == "NOT_ATTENDED") {
                                thisResult.attendanceStatus = "No-Show";
                            } else if (bookingAttendance.items[0].status == "ATTENDED") {
                                thisResult.attendanceStatusattendanceStatus = "Checked-In";
                            }
                        }

                    } catch {
                        thisResult.attendanceStatus = "Booked";
                    }

                    /* Need to check for the extistance of a pricing plan before copying it into result */
                    if (thisBooking.paymentDetails) {
                        if (thisBooking.paymentDetails.pricingPlanDetails) {
                            thisResult.pricingPlan = thisBooking.paymentDetails.pricingPlanDetails.planName;
                        }
                    }

                    /* Query for the const who made the booking to get details about the contact */
                    try {
                        let bookingContact = await contacts.getContact(thisBooking.formInfo.contactDetails.contactId);
                        thisResult.firstName = bookingContact.info.name.first;
                        thisResult.lastName = bookingContact.info.name.last;
                        if (bookingContact.info.birthdate) thisResult.birthDate = new Date(bookingContact.info.birthdate);
                        thisResult.partnerId = bookingContact.info.extendedFields["custom.partner-membership-id"];

                        /* Create a label string that is a concatinization of all the custome labels separated by semicolons */
                        thisResult.labels = "";
                        for (let labelKey of bookingContact.info.labelKeys) {
                            if (labelKey.substr(0, 7) == "custom.") {
                                thisResult.labels += labelKey.substr(7) + ";";
                            }
                        }
                    } catch {
                        /* If no contact, use the name provided during the booking process */
                        thisResult.firstName = thisBooking.formInfo.contactDetails.firstName;
                    }

                    /* Add this booking to the results */
                    returnResults.push(thisResult);
                    /* } */
                }
            }
            return returnResults;
        } else {
            Promise.reject(
                new Error('Invalid Password'));

        }

    } catch (error) {
        Promise.reject(
            new Error('customBackend.jsw > getBookingsPage error - details -' + error.message));
    }
}

My solution is implemented on a Dashboard Page. These are really easy to create. From the Wix Editor, when you add a page, you can choose Site Page or Dashboard Page. As you can guess, the Dashboard Page shows up as one of the menu items on your site’s Wix dashboard. Call it whatever you want. You can have as many as you want. They look just like any other page, but you can’t access them directly from the website. Only people with access to your dashboard can access them.

This page has a JS module, which I will post in a separate reply. Elements on this page include at least the following (organize the page and its elements any way you want):

Element TypeElement IDDescription
Date Picker Input#dateStartAllows user to specify the start date for the bookings to be downloaded
Date Picker Input#dateEndAllows user to specify the end date for the bookings to be downloaded
Password Input#passwordUser enters password to authenticate against Wix Secret
Button#partnerPassReportButtonInitiates the partner bookings reports
Text#statusTextUsed to provide status updates while the report is being prepared
Text#errorTextUsed to provide error messages generated from this module
HTML#downloadLinkProvides a link to download an HTML table as an Excel-readable file. Will be described in a separate reply.
Table#bookingsListAll of the partner bookings data will be loaded into this table. It must include the following columns: sessionDate (date format), partnerLocation, memberId, firstName, lastName, birthDate (date format), partnerOrg, partnerPlan, partnerId, memberBookingCount, memberDateBookingCount
Table(custom, one for each partner)The partner listings for the specific partner will be loaded into this table prior to being exported to Excel. Include any combination of the columns from the bookingsList table, depending on the requirements of your partner organization. If the partner organization provides an Excel template for usage uploads, use that as a model.

The code for this page will initialize the start and end date to select all bookings from the previous month, but the user can override those dates. The user must enter the Partner Password, will be validated against the password in the Wix Secrets manager. The button will be set at initialization by the code to launch the load bookings feature when it is pressed. The entire process takes maybe 10 minutes to go through the approximately 1400 bookings we have in a typical month. Almost that entire time is consumed by the bookings queries. Your mileage may vary.

Caution, the error handling may have some holes in it.

I found several examples online of people downloading data to Excel from Wix, but nothing that exactly did what I needed. This is what I came up with.

An HTML element (id = #downloadLink, I choose to hide the element) includes an anchor element (not related to the anchor element in Wix) that creates a references to a “file” created by the JS code, assigns it a MIME type for Excel files, and downloads that file.

This HTML element has an event listener to accept messages from its parent window (which is the dashboard page, in this case). That message has one parameter, a single string. That string is the concatenation of two things: the filename to download to, and the actual data to be downloaded.

The data to be downloaded, in this case, is an HTML table, which Excel can import. You’d think it would be easy to send the table HTML code from the parent window in which the table already exists to this element, but I haven’t figured it out. Instead, I had to write JS code to traverse the table and regenerate the HTML. If anyone knows an easier way, let me know.

The standard MIME type for Excel is application/vnd.ms-excel but I chose to use application/vnd.openxmlformats-officedocument.spreadsheetml.sheet in case someone is using something like Google Sheets.

When a file is generated it is saved into the user’s Downloads folder. I found that when you open it Excel generates a message saying that the document extension (.xls) doesn’t match the file type (HTML). It is fine to ignore this message, Excel handles HTML tables just fine. After you open the file in Excel, you’ll want to Save As, so that you can change the file into an actual XLS or XLSX worksheet from an HTML-formatted file.

To invoke this HTML download from the Wix page, include the following (or equivalent):

let exportTable = "filename.xls" + "<table>All your table row data</table>";
$w('#downloadLink').postMessage(exportTable);

The HTML code for the downloadLink element follows.

<html><body>
    <a id="downloadAnchor"</a>
    <script>
      window.addEventListener('message', event => {
        dataStart = event.data.indexOf("<");
      	anchor = document.querySelector('#downloadAnchor');
      	hrefString = "data:application/vnd.openxmlformats-officedocument.spreadsheetml.sheet, ";
        hrefString += event.data.substr(dataStart);
      	anchor.href = hrefString;
      	anchor.download = event.data.substr(0,dataStart);
        anchor.click();
      });
</script></body></html>

Here is the page code for my solution. I hope someone can make use of this, or adapt it to something you can use.

import { getBookingsPage } from 'backend/customBackend.jsw';

// ****************************************************
// Tuning parameters
// ****************************************************
// Bookings data are retrieved one page at a time from the Wix back end.
// bookingsPageSize is the number of bookings records to retrieve at one.
// Because the bookings APIs are quite slow, we need to make sure we don't bog down.
// If this parameter to too large, it will take longer to retrieve each page,
// and hence increase the likelihood of an error 504 (gateway timeout).
// If you get too many of those errors, try reducing the page size.
const bookingsPageSize = 20;

// Loop delay (in milliseconds) is inserted after each page.
// If you get too many error 429 (too many requests), you can try increasing this number.
// Maybe it will also help give the garbage collector time to run if this page runs out of memory.
// All other things being equal, leave this as zero to allow this page to run as fast as possible.
const loopDelay = 0;

// ****************************************************
// Customization parameters
// ****************************************************
// These parameters are specific to each website and its partners
// This page will only process bookings that were purchased using a pricing plan
// that starts with the pricingPlanName string. For example, it will match:
//      Partner Memebership
//      Partnership Pass
//      Partner XYZ
// Everything else will be ignored.
const pricingPlanName = "Partner";

// Each contact who is a partner member must have a custom label set on the contact in Wix.
// The label must start with one of the label strings in this table.
// The org field identifies the organization with which the partnership exists.
// The plan field identifies the specific plan that the specific member participates in.
const partnerLabels = [
    { label: "plan-one-label", org: "Org1", plan: "Plan One Name" },
    { label: "plan-two-label", org: "Org1", plan: "Plan Two Name" },
    { label: "plan-three-label", org: "Org2", plan: "Org2 Plan Name" }
];

// Each partner organization may have a specific code to identify each possible booking location.
// The partnerLocations table defines that mapping.
// There must be one enter per location per partner organization.
const partnerLocations = [
    { partner: "Org1", location: "East Center", partnerLocation: "East" },
    { partner: "Org1", location: "North Center", partnerLocation: "North" },
    { partner: "Org1", location: "South Center", partnerLocation: "South" },
    { partner: "Org1", location: "Public Pool", partnerLocation: "Pool" },
    { partner: "Org2", location: "East Center", partnerLocation: "ORG2LOC1234" },
    { partner: "Org2", location: "North Center", partnerLocation: "ORG2LOC1243" },
    { partner: "Org2", location: "South Center", partnerLocation: "ORG2LOC1257" },
    { partner: "Org2", location: "Public Pool", partnerLocation: "ORG2LOC1275" }
];

// Specific information about each partner
// siteId is a partner-specific string used for all bookings data reported to that organization
// detailBookings: if true, report all bookings data, session-by-session,
//    if false, report a count of total bookings by each member.
// dedup: if true, combine multiple bookings on the same day into a single booking,
//    if false, report multiple bookings on a single day as separate visits.
//    Note: The total count of bookings by member will always be deduped (only count days on which visits occurred).
// tableId: This is the ID of the table element on this page in which bookings data for this partner
//    will be consolidated.
// fileName: The name of the file into which bookings data for this partner will be exported.
//    Note that the start date of the bookings data will be appended to the filename,
//    and  an extension of ".xls" will be added
const partnerConfig = [
    { partner: "Org1", siteId: "SITEXX9876", detailBookings: false, dedup: true, tableId: "#org1UsageData", filename: "Org1 Usage Report" },
    { partner: "Org2", siteId: "", detailBookings: true, dedup: true, tableId: "#org2UsageData", filename: "Org2 Usage" }
];

// ****************************************************
// This code runs when this page is first loaded
// ****************************************************
$w.onReady(function () {
    // Set the action to be taken when the button is clicked
    $w('#partnerPassReportButton').onClick(() => loadBookings());

    // Initialize the form with the default start and end date.
    // Note that by default (if the user doesn't everride these)
    //    this page will report on all booking data for the previous month.
    // Note that the reporting is NOT inclusive of the end date.
    let yr = new Date().getUTCFullYear();
    let mon = new Date().getMonth();
    $w('#dateStart').value = new Date(yr, mon - 1, 1);
    $w('#dateEnd').value = new Date(yr, mon, 1);

    // The status text on this page is used to keep the user informed about progress
    $w('#statusText').text = "Ready!";
    $w('#statusText').show();

    // The error text is used, of course, to report errors to the user.
    $w('#errorText').hide();
})

// ****************************************************
// This function will wait for the specified number of milliseconds.
// This only exists to possibly work around Wix/HTTP problems related to execing the capacity of the server.
// Logically should not be necessary.
// In other words, a kludge that can be used if things get weird.
// ****************************************************
async function wait(ms) {
    return new Promise(resolve => {
        setTimeout(resolve, ms);
    });
}

// ****************************************************
// This is the main function for this page.
// It does all the work of retrieving the specified bookings data, processing it as required,
// and outputting it in the format requried by each of the partner organizations.
// ****************************************************
async function loadBookings() {

    $w('#errorText').hide(); /* hidden until needed */
    $w('#statusText').text = "Loading first bookings page ..."; /* Yeah, it's slow. Keep user entertained */
    if (!($w('#dateStart').valid && $w('#dateEnd').valid)) {
        $w('#errorText').show();
        $w('#errorText').text = "Error in form fields";
        return;
    }

    /* Turn off the button for the duration to avoid confusion */
    $w('#partnerPassReportButton').disable();

    try {
        // This function uses the bookingsList table on this page to store the intermediate results
        // and to keep the user entertained by showing those intermediate results
        $w('#bookingsList').rows = [];

        /* Main loop. Get pages of bookings data from the backend and process each page */
        for (let index = 0, moreBookings = true; moreBookings; index += bookingsPageSize) {
            /* Get a page of bookings data */
            let results = await getBookingsPage($w('#dateStart').value, $w('#dateEnd').value, index, bookingsPageSize, $w('#password').value);

            /* handle the page of results, if any */
            if (results && results.length > 0) {
                /* Keep user entertained by updating count of bookings and data of latest */
                $w('#statusText').text = "Loading bookings for " +
                    results[0].sessionTime.toLocaleDateString() + ", " + index.toString() +
                    " complete ...";

                /* loop through each of the individual booking results */
                for (let result of results) {
                    /* Ignore any booking in which the pricing plan isn't a partnr plan, 
                       or in which the booking was marked as a no-show */
                    if (result.pricingPlan &&
                        (result.pricingPlan.substr(0, pricingPlanName.length) == pricingPlanName) &&
                        (result.attendanceStatus != "No-Show")) {
                        let thisBooking = result;

                        /* Our Member ID is set to the same as the Partner's Member ID,
                           because Wix doesn't have a contact ID that is visible. */
                        thisBooking.memberId = thisBooking.partnerId;

                        /* The date of the booking is simple the date portion of the session time */
                        thisBooking.sessionDate = new Date(
                            thisBooking.sessionTime.getFullYear(),
                            thisBooking.sessionTime.getMonth(),
                            thisBooking.sessionTime.getDate()
                        );

                        /* Contact custom labels are passed in as a string, separated by semicolons */
                        /* Go through each one and check to see if it is one of the labels indicating a parter membr */
                        let bookingLabelArray = thisBooking.labels.split(";");
                        for (let thisBookingLabel of bookingLabelArray) {
                            for (let thisPartnerLabel of partnerLabels) {
                                if (thisBookingLabel.substr(0, thisPartnerLabel.label.length) == thisPartnerLabel.label) {
                                    /* If we found a matching label, set the partner organization and plan */
                                    thisBooking.partnerOrg = thisPartnerLabel.org;
                                    thisBooking.partnerPlan = thisPartnerLabel.plan;
                                }
                            }
                        }

                        /* Each partner may have its own identifier for each location */
                        /* Use the mapping table to set the partner location based on the booking's session location */
                        for (let thisPartnerLocation of partnerLocations) {
                            if (thisBooking.sessionLocation.substr(0, thisPartnerLocation.location.length) == thisPartnerLocation.location &&
                                thisBooking.partnerOrg == thisPartnerLocation.partner) {
                                thisBooking.partnerLocation = thisPartnerLocation.partnerLocation;
                            }
                        }

                        /* Add this booking to the list of partner bookings */
                        let rows = $w('#bookingsList').rows;
                        rows.push(thisBooking);
                        $w('#bookingsList').rows = rows;
                    }
                }
            } else moreBookings = false;

            /* This delay shouldn't be necessary, but added here in case there are problems with the server getting overwhelmed */
            await wait(loopDelay);
        }

        /* Bookings are already sorted by session time */
        /* Sort them additionally by member, so that all of a member's bookings are together */
        /* Order will be Lasst Name, then First Name, then Session Time */
        let rows = $w('#bookingsList').rows;
        $w('#statusText').text = "Sorting " + rows.length.toString() + " bookings ...";
        rows.sort(function (a, b) {
            let aLast = a.lastName.toLowerCase();
            let aFirst = a.firstName.toLowerCase();
            let bLast = b.lastName.toLowerCase();
            let bFirst = b.firstName.toLowerCase();
            if (aLast > bLast) { return 1; } else if (aLast < bLast) { return -1; } else if (aFirst > bFirst) { return 1; } else if (aFirst < bFirst) { return -1; } else { return 0; }
        });

        /* This next section of code will dedup and count the bookings for each member */
        let firstMemberResult = -1; /* index of first booking for this member */
        let firstMemberDateResult = -1; /* index of first booking for this member on this date */
        let currentMemberId = ""; /* Member ID is used to differentiate members */
        let currentBookingDate = null;
        for (let i = 0; i < rows.length; i++) {
            /* Check to see if same member as previous row */
            if (rows[i].memberId == currentMemberId) {
                /* Check to see if same date as previous row */
                if (+rows[i].sessionDate == +currentBookingDate) {
                    /* Same member and date as previous row, so incremement count of first booking on this date
                    and set the count for this row to zero. It will be ignored as a duplicate */
                    rows[firstMemberDateResult].memberDateBookingCount++;
                    rows[i].memberDateBookingCount = 0;
                } else {
                    /* Same member, different date. Increment running count for the first booking for this member */
                    firstMemberDateResult = i;
                    currentBookingDate = rows[i].sessionDate;
                    rows[i].memberDateBookingCount = 1;
                    rows[firstMemberResult].memberBookingCount++;
                }
                /* Same member as previous row, so set booking count to zero */
                rows[i].memberBookingCount = 0;
            } else {
                /* First booking for a member */
                firstMemberResult = i;
                firstMemberDateResult = i;
                currentMemberId = rows[i].memberId;
                currentBookingDate = rows[i].sessionDate;
                rows[i].memberBookingCount = 1;
                rows[i].memberDateBookingCount = 1;
            }
        }
        $w('#bookingsList').rows = rows;

        /* This section of code does all the partner-specific processing */
        for (let part of partnerConfig) {
            $w('#statusText').text = "Exporting " + part.partner + " bookings ...";

            /* Create the output table using those bookings that match this partner */
            let partnerRows = [];
            for (let row of rows) {
                if (row.partnerOrg == part.partner) {
                    /* Set the Site ID field, specific to this partner */
                    /* Use of this field is specific to the partner, and may not be relevant for all */
                    row.siteId = part.siteId;

                    /* Include the row depending on the configuration parameters for this partner */
                    if ((row.memberBookingCount > 0) ||
                        (part.detailBookings && row.memberDateBookingCount > 0) ||
                        (!part.dedup)) {
                        partnerRows.push(row);
                    }
                }
            }

            /* Store the results in the table configured for this partner */
            $w(part.tableId).rows = partnerRows;

            /* Now we are going to output the table to Excel */
            /* First create the filename. Use the filename from the configuratuion table */
            /* Then append the date of the first booking and the extension */
            /* filenameYYMMDD.xls */
            let yr1 = $w('#dateStart').value.getFullYear().toString().substr(2);
            let mon1 = ($w('#dateStart').value.getMonth() + 1).toString();
            if (mon1.length < 2) { mon1 = "0" + mon1; }
            let dt1 = $w('#dateStart').value.getDate().toString();
            if (dt1.length < 2) { dt1 = "0" + dt1; }
            let exportTable = part.filename + yr1 + mon1 + dt1 + ".xls";

            /* The Excel export function takes as its parameter a single string */
            /* filename followed by the html for the table to be exported */
            exportTable += "<table>";
            exportTable += "<tr>";
            /* Add the column headings */
            for (let col of $w(part.tableId).columns) {
                exportTable += "<th>";
                exportTable += col.label;
                exportTable += "</th>";
            }
            exportTable += "</tr>";

            /* Now add the table data */
            for (let row of partnerRows) {
                exportTable += "<tr>";
                for (let col of $w(part.tableId).columns) {
                    exportTable += "<td>";
                    let cellData = "";
                    if (row[col.dataPath]) {
                        cellData = row[col.dataPath];
                        /* If the cell data is a date, convert it to a string */
                        if (row[col.dataPath] instanceof Date) {
                            cellData = row[col.dataPath].toLocaleDateString();
                        } else {
                            cellData = row[col.dataPath];
                        }
                    }
                    exportTable += cellData;
                    exportTable += "</td>";
                }
                exportTable += "</tr>";
            }
            exportTable += "</table>";
            /* Send the download informatino (filename+table) to the HTML element to download */
            $w('#downloadLink').postMessage(exportTable);
        }
    } catch (error) {
        console.error('loadBookings error - ' + error.message);
    } finally {
        /* All done, let the user know, and let them do it again */
        $w('#partnerPassReportButton').enable();
        $w('#statusText').text = "Processing complete!";
    };
}
1 Like

I discovered there is a problem with the Date Picker element. It appears there is a bug (?) in Wix, in which the Date picker element is not fully loaded before the onReady function is called. This does not affect the behavior in Preview mode (which is why I didn’t find it earlier). However, in Live mode, I found that my onReady function set the start and end date widgets to their initial values (the first of the previous and current month) before it was initialized, and my initial dates were (partially) overridden by the current date.

Simple workaround – wait a second before setting the elements’ initial values.

$w.onReady(function () {
    // Set the action to be taken when the button is clicked
    $w('#partnerPassReportButton').onClick(() => loadBookings());

    // Initialize the form with the default start and end date.
    // Note that by default (if the user doesn't everride these)
    //    this page will report on all booking data for the previous month.
    // Note that the reporting is NOT inclusive of the end date.
    let yr = new Date().getUTCFullYear();
    let mon = new Date().getMonth();
    setTimeout(() => {
        /* Why is there a one second timeout here?
        It appears there is a bug in Wix in which the Date Picker is not fully loaded before onReady is called. */
        $w('#dateStart').value = new Date(yr, mon - 1, 1);
        $w('#dateEnd').value = new Date(yr, mon, 1);

        // The status text on this page is used to keep the user informed about progress
        $w('#statusText').text = "Ready!";
        $w('#statusText').show();
        // The error text is used, of course, to report errors to the user.
        $w('#errorText').hide();
    }, 1000);
})

I made one more minor change, which I will leave as an exercise for the reader. Instead of outputting the table as an HTML file with an XLS extension, I downloaded as a CSV file with a CSV extension, but but kept the Excel MIME type. This is much cleaner, and doesn’t cause Excel to complain about file type and extension mismatch.