Offline Report Cards and Custom Dashboards

Avni allows you to create different indicator reports that are available offline to the field users. These reports help field users to derive more insights on the captured data.

Creating an offline report is a two-step process. First, we need to create a report card that holds the actual query function. Second, we group multiple cards into to a dashboard.

Creating a Report Card

Creating a new report card is no different than creating any other Avni entity. Open app designer and go to the report card tab. Click on the new report card and provide the details like name description, etc.

Report Card Types

Report cards can be of 2 types - 'Standard' and 'Custom'. The logic used to display the values in 'Standard' type cards are already implemented in Avni whereas the logic needs to be written by the implementer for 'Custom' type cards.

  1. Standard Report Cards, the different types of which are as follows (Entity specified in brackets indicates the type of entity listed on clicking on the card):

    • Pending approval (Entity Approval Statuses)

    • Approved (Entity Approval Statuses)

    • Rejected (Entity Approval Statuses)

    • Scheduled visits (Subjects)

    • Overdue visits (Subjects)

    • Recent registrations (Subjects)

    • Recent enrolments (Subjects)

    • Recent visits (Subjects)

    • Total (Subjects)

    • Comments (Subjects)

    • Call tasks (Tasks)

    • Open subject tasks (Tasks)

    • Due checklist (Individuals)

  2. Custom Report cards: Report card with configurable Query, which returns a list of Individuals as the response. Length of the list is shown on the card and on clicking the card, the list of Individuals returned is shown. Please note that the query function can return a list of Individuals or an object with these properties, { primaryValue: '20', secondaryValue: '(5%)', lineListFunction }, here lineListFunction should always return the list of subjects.

Standard Report Card Type Filters

Filters can be added at the report card level for certain standard report types. The following filters are supported:

  1. Subject Type
  2. Program
  3. Encounter Type
  4. Recent Duration

Subject Type, Program and Encounter Type filters are supported for 'Overdue Visits', 'Scheduled Visits', 'Total' and 'Recent' types ('Recent registrations', 'Recent enrolments', 'Recent visits').


Filters can also be configured at the dashboard level (covered below). If a filter is configured both at the report card level and the dashboard level, the filter at the report card level is applied first. Hence, mixing of the same type of filter at both levels should be avoided as it could lead to the unintuitive behaviour of the field user selecting a value, say 'Household' for the subject type filter at the dashboard level but still seeing the numbers for the 'Individual' subject type which is configured at the report card level.

Creating a Dashboard

After all the cards are done it's time to group them together using the dashboard. Offline Dashboards have the following sub-components:

  • Sections : Visual Partitions used to club together cards of specific grouping type
  • Offline (Custom) Report Cards : Usually Clickable blocks with count information about grouping of Individuals or EntityApprovals of specific type
  • Filters : Configurable filters that get applied to all "Report Cards" count and listing

Users with access to the "App Designer" can Create, Modifiy or Delete Custom Dashboards as seen below.

Steps to configure a Custom Dashboard

  • Click on the dashboard tab on the app designer and click on the new dashboard.
  • This will take you to the new dashboard screen. Provide the name and description of the dashboard.
  • You can create sections on this screen and
  • Select all the cards you need to add to the section in the dashboard.
  • After adding all the cards, you can re-arrange the cards in the order you want them to see in the field app.

Dashboard Filters

You can also create filters for a dashboard on the same screen by clicking on "Add Filter". This shows a popup as in the below screenshot where you can configure your filter and set the filter name, type, widget type and other values based on your filter type.

Once all the changes are done. Save the dashboard.

For the filters to be applied to the cards in the dashboard, the code for the cards will need to handle the filters.

Sample Code for handling filters in report card:

'use strict';
({params, imports}) => {
//console.log('------>',params.ruleInput.filter( type => type.Name === "Gender" ));
//console.log("params:::", JSON.stringify(params.ruleInput));
  let individualList = params.db.objects('Individual').filtered("voided = false and subjectType.name = 'Individual'" )
     .filter( (individual) => individual.getAgeInYears() >= 18 && individual.getAgeInYears() <= 49  &&  individual.getObservationReadableValue('Is sterilisation done') === 'No');
  
  if (params.ruleInput) {

       let genderFilter = params.ruleInput.filter(rule => rule.type === "Gender");
       let genderValue = genderFilter[0].filterValue[0].name;
      
        console.log('genderFilter---------',genderFilter);
        console.log('genderValue---------',genderValue);        
        
      return individualList
     .filter( (individual) => {
     console.log("individual.gender:::", JSON.stringify(individual.gender.name));
     return individual.gender.name === genderValue;
     });
     }
     else return individualList;
};

Assigning custom Dashboards to User Groups

Custom Dashboards created need to be assigned specifically to a User Group, in-order for Users to see it on the Avni-client mobile app. You may do this, by navigating to the "Admin" app -> "User Groups" -> (User_GROUP) -> "Dashboards" tab, and assigning one or more Custom Dashboards to a User-Group.

In addition, You can also mark one of these Custom Dashboards as the Primary (Is Primary: True) dashboard from the "Admin" app -> "User Groups" -> (User_GROUP) -> "Dashboards".

Using the Dashboard in the Field App

After saving the dashboard sync the field app, and from the bottom "More" tab click on the "Dashboards" option. It will take you to the dashboard screen and will show all the cards that are added to the dashboard.

Report cards only passing List of subjects.

Report cards only passing List of subjects.

566

Report cards returning primaryValue and secondaryValue object

Clicking any card will take the user to the subject listing page, which will display all the subject names returned by the card query.

Users can click on any subject and navigate to their dashboard.

Secondary Dashboard

Web app configuration

As part of Avni release 8.0.0, a new feature of a secondary dashboard is added which can be configured at user group level to populate an additional option on the Avni mobile app bottom drawer to navigate to a secondary dashboard. This configuration has to be done in the user group in Avni web app.

  • By navigating to the dashboard section in a particular user group where dashboards can be added to user groups, the secondary dashboard can be defined apart from the home dashboard. As shown in the screenshot below, any dashboard can be selected as the secondary dashboard.

Secondary dashboard in mobile app

The configuration mentioned above would display the particular dashboard in the mobile app as given below. This would allow users to access the home and secondary dashboard from the bottom drawer of the mobile app instead of navigating to the more page.

Clash in Dashboards configuration across different UserGroups

In-case a User belongs to multiple UserGroups, where-in each has a different Primary and/or Secondary Dashboards, then the behaviour is undeterministic. I.e, any of the possible Primary Dashboards across the various UserGroups, would show up as the Primary on the app. Similar behaviour should be expected of the Secondary Dashboard as well.

Report card query example

As mentioned earlier query can return a list of Individuals or an object with properties, { primaryValue: '20', secondaryValue: '(5%)', lineListFunction }. DB instance is passed using the params and useful libraries like lodash and moment are available in the imports parameter of the function. Below are some examples of writing the lineListFunction.

The below function returns a list of pregnant women having any high-risk conditions.

'use strict';
({params, imports}) => {
    const isHighRiskWomen = (enrolment) => {
        const weight = enrolment.findLatestObservationInEntireEnrolment('Weight');
        const hb = enrolment.findLatestObservationInEntireEnrolment('Hb');
        const numberOfLiveChildren = enrolment.findLatestObservationInEntireEnrolment('Number of live children');
        return (weight && weight.getReadableValue() < 40) || (hb && hb.getReadableValue() < 8) ||
            (numberOfLiveChildren && numberOfLiveChildren.getReadableValue() > 3)
    };
    return {
      lineListFunction: () => params.db.objects('Individual')
        .filtered(`SUBQUERY(enrolments, $enrolment, SUBQUERY($enrolment.encounters, $encounter, $encounter.encounterType.name = 'Monthly monitoring of pregnant woman' and $encounter.voided = false).@count > 0 and $enrolment.voided = false and voided = false).@count > 0`)
        .filter((individual) => individual.voided === false && _.some(individual.enrolments, (enrolment) => enrolment.program.name === 'Pregnant Woman' && isHighRiskWomen(enrolment)))
    }
};

It is important to write optimised query and load very less data in memory for processing. There will be the cases where query can't be written in realm and we need to load the data in memory, but remember more data we load into the memory slower will be the reports. As an example consider below two cases, in the first case we directly query realm to fetch all the individuals enrolled in Child program, but in the second case we first load all individuals into memory and then filter those cases.

'use strict';
({params, imports}) => ({
    lineListFunction: () => params.db.objects('Individual')
        .filtered(`SUBQUERY(enrolments, $enrolment, $enrolment.program.name = 'Child' and voided = false).@count > 0`)
});
'use strict';
({params, imports}) => {
    return params.db.objects('Individual')
        .filter((individual) => individual.voided === false && _.some(individual.enrolments, (enrolment) => enrolment.program.name === 'Child'))
};

For using the filters in the rules also check section on Dashboard Card Rule here - Writing rules

Performance of queries

The report cards requires one to return a list individuals. This can be done by:

  1. Performing db.objects on Individual and filtering them down.
  2. Performing db.objects on descendants of Subject (like encounter, enrolment), filter them down, then return list of Individuals from each filtered object. Example is given below.

Implementation Patterns for writing performant queries

Please refer to this reference for Realm Query Language.

To understand difference between filter and filtered that is referred below, please see, https://avni.readme.io/docs/writing-rules#difference-between-filter-and-filtered

Please also get in touch with platform team if you identify a new pattern and a new type of requirement where none of the following fits.

  1. Filter based on chronological data
    1. The matching has to be done on specific chronological descendant entity. e.g. first encounter of a specific type, recent encounter of specific type.
    2. In this case performing db.objects on Individual will lead to either very complex queries or will demand performing filtering in memory using JS.
    3. In this case one can do db.objects on descendant entity and then use something like .filtered('TRUEPREDICATE sort(programEnrolment.individual.uuid asc , encounterDateTime desc) Distinct(programEnrolment.individual.uuid)') to get the chronological relevant entity at the top in each group. Distinct keyword picks only the first entity in the sorted group.
    4. After performing filtered, one can return Subjects by performing list.map(enc => enc.programEnrolment.individual)
  2. Filter based exact observation value
    1. Matching observations by loading them in memory and calling JS functions will lead to slower reports.
    2. A combination of subquery and realm query based match will have much better performance. For example: matching observation that has a specific value - SUBQUERY(observations, $observation, $observation.concept.name = 'Phone number' and $observation.valueJSON CONTAINS '7555795537'
  3. Filter based on exact specific coded observation value
    1. Matching coded value using its name will require one to load data in memory and perform the match. But this could result in sub-optimal performance. Hence the readability of the report should be sacrificed here for performance.
    2. The query will be like SUBQUERY(observations, $observation, $observation.concept.uuid = 'Marital Status' and $observation.valueJSON CONTAINS 'fb1080b4-d1ec-4c87-a10d-3838ba9abc5b'
    3. Please note here that multiple observations can be matched here using OR, AND etc.
  4. Filter based on a custom observation value expression.
    1. Instead of matching against a single value match using numeric expression. e.g. match BMI greater than 20.0.
    2. This kind of match cannot be done using realm query and implementing them in JS may result in poor performance.
    3. In such cases we should find out the significance of magic number 20.0. Usually we also have a coded decision observation associated that has meaning behind 20.0, like malnutrition status, BMI Status etc. If there is one then we should match against that using pattern 3 above. If such observation is not present then consider adding them to decisions.
    4. In requirements where such associated coded observation are not present and cannot be added, the performance will depend on the number of observations and entities being matched. If this number is large the performance is expected to be slow, it is better to avoid making reports, or move such reports to their own dashboard - so that they don't impact the usability of other reports.

Detailed examples

DEPRECATED: Avoid using generic functions:

  • The following is deprecated cause we should use Filter based on chronological data pattern from above.
  • To find observation of a concept avoid using the function findLatestObservationInEntireEnrolment unless absolutely necessary since it searches for the observation in all encounters and enrolment observations. Use specific functions.
  • Eg: To find observation in enrolment can use the function enrolment.findObservation or to find observations in specific encounter type can get the encounters using enrolment.lastFulfilledEncounter(...encounterTypeNames) and then find observation. Refer code examples for the below 3 usecases.
  • Find children with birth weight less than 2. Birth weight is captured in enrolment
    
    'use strict';
    ({params, imports}) => {
        const isLowBirthWeight = (enrolment) => {
            const obs = enrolment.findObservation('Birth Weight');
            return obs ? obs.getReadableValue() <= 2 : false;
        };
        return params.db.objects('Individual')
            .filtered(`voided = false and SUBQUERY(enrolments, $enrolment, $enrolment.program.name = 'Child' and $enrolment.programExitDateTime = null and $enrolment.voided = false).@count > 0`)
            .filter((individual) => _.some(individual.enrolments, enrolment => enrolment.program.name === 'Child' && _.isNil(enrolment.programExitDateTime) && !enrolment.voided && isLowBirthWeight(enrolment)))
    };
    
    'use strict';
    ({params, imports}) => {
        const isLowBirthWeight = (enrolment) => {
            const obs = enrolment.findLatestObservationInEntireEnrolment('Birth Weight');
            return obs ? obs.getReadableValue() <= 2 : false;
        };
        return params.db.objects('Individual')
            .filtered(`SUBQUERY(enrolments, $enrolment, $enrolment.program.name = 'Child' and $enrolment.programExitDateTime = null and $enrolment.voided = false and SUBQUERY($enrolment.observations, $observation, $observation.concept.uuid = 'c82cd1c8-d0a9-4237-b791-8d64e52b6c4a').@count > 0 and voided = false).@count > 0`)
            .filter((individual) => individual.voided === false && _.some(individual.enrolments, enrolment => enrolment.program.name === 'Child' && isLowBirthWeight(enrolment)))
    };
    
    do voided check first in realm instead of JS - helps in filtering ahead
    Check for concept where it is used - no need to check in all encounters and enrolment
    
    Find MAM status from value of Nutritional status concept captured in Child Followup encounter
    
    // While this example is illustrating the right JS function to use, but it may be better to filter(ed)
    // encounter schema than to start with Individual
    // i.e. someting like db.objects("ProgramEncounter").filtered("programEnrolment.individual.voided = false AND programEnrolment.voided = false AND ...")
    // then return Individuals using .map(enc => enc.programEnrolment.individual) after filtering all program encounters
    'use strict';
    ({params, imports}) => {
        const isUndernourished = (enrolment) => {
            const encounter = enrolment.lastFulfilledEncounter('Child Followup'); 
            if(_.isNil(encounter)) return false; 
           
           const obs = encounter.findObservation("Nutritional status of child");
           return (!_.isNil(obs) && _.isEqual(obs.getValueWrapper().getValue(), "MAM"));
        };
        
        return params.db.objects('Individual')
            .filtered(`voided = false and SUBQUERY(enrolments, $enrolment,$enrolment.program.name = 'Child' and $enrolment.programExitDateTime = null and $enrolment.voided = false and SUBQUERY($enrolment.encounters, $encounter, $encounter.encounterType.name = 'Child Followup' and $encounter.voided = false).@count > 0).@count > 0`)
            .filter((individual) => individual.getAgeInMonths() > 6 && _.some(individual.enrolments, enrolment => enrolment.program.name === 'Child' && _.isNil(enrolment.programExitDateTime) && !enrolment.voided && isUndernourished(enrolment)))
    };
    
    'use strict';
    ({params, imports}) => {
        const isUndernourished = (enrolment) => {
            const obs = enrolment.findLatestObservationInEntireEnrolment('Nutritional status of child');
            return obs ? _.includes(['MAM'], obs.getReadableValue()) : false;
        };
        return params.db.objects('Individual')
            .filtered(`SUBQUERY(enrolments, $enrolment, $enrolment.program.name = 'Child' and $enrolment.programExitDateTime = null and $enrolment.voided = false and SUBQUERY($enrolment.encounters, $encounter, $encounter.encounterType.name = 'Child Followup' and $encounter.voided = false and SUBQUERY($encounter.observations, $observation, $observation.concept.uuid = '3fb85722-fd53-43db-9e8b-d34767af9f7e').@count > 0).@count > 0 and voided = false).@count > 0`)
            .filter((individual) => individual.voided === false && individual.getAgeInMonths() > 6 && _.some(individual.enrolments, enrolment => enrolment.program.name === 'Child' && isUndernourished(enrolment)))
    };
    
    Check only in specific encounter type
    
    Find sick children using the presence of value for concept 'Refer to the hospital for' which is not a mandatory concept
    
    // also see comments in Recommended way for use case 2
    'use strict';
    ({params, imports}) => {
        const isChildSick = (enrolment) => {
          const encounter = enrolment.lastFulfilledEncounter('Child Followup', 'Child PNC'); 
          if(_.isNil(encounter)) return false; 
           
          const obs = encounter.findObservation('Refer to the hospital for');
          return !_.isNil(obs);
        };
        
        return params.db.objects('Individual')
            .filtered(`voided = false and SUBQUERY(enrolments, $enrolment, $enrolment.program.name = 'Child' and $enrolment.programExitDateTime = null and $enrolment.voided = false).@count > 0`)
            .filter(individual => _.some(individual.enrolments, enrolment => enrolment.program.name === 'Child' && _.isNil(enrolment.programExitDateTime) && !enrolment.voided && isChildSick(enrolment)))
    };
    
    'use strict';
    ({params, imports}) => {
        const isChildSick = (enrolment) => {
             const obs = enrolment.findLatestObservationFromEncounters('Refer to the hospital for');    
             return obs ? obs.getReadableValue() != undefined : false;
        };
        
        return params.db.objects('Individual')
            .filtered(`SUBQUERY(enrolments, $enrolment, $enrolment.program.name = 'Child' and $enrolment.programExitDateTime = null and $enrolment.voided = false).@count > 0`)
            .filter((individual) => individual.voided === false && (_.some(individual.enrolments, enrolment => enrolment.program.name === 'Child' && isChildSick(enrolment))) )
    };
    
    Check only in last encounter, not all encounters since the concept is not a mandatory concept. 
    Using findLatestObservationFromEncounters will check in all encounters and mark child has sick even if the concept value had represented sick in any of the previous encounters, resulting in bug, since the concept is not a mandatory concept.
    

Based on the use case decide whether to write the logic using realm query or JS.

  • Not always achieving the purpose using realm queries might be efficient/possible.

    • DEPRECATED cause we should use Filter based on chronological data pattern from above. Eg: consider a use case where a mandatory concept is used in a program encounter. Now to check the latest value of the concept, its sufficient to check the last encounter and need not iterate all encounters. Since realm subquery doesn't support searching only in the last encounter, for such usecases, using realm queries not only becomes slow and also sometimes inappropriate depending on the usecase. So in such cases, using JS code for the logic, is more efficient. (refer the below code example)

      • Find dead children using concept value captured in encounter cancel or program exit form.
        
        'use strict';
        ({params, imports}) => { 
           const moment = imports.moment;
        
           const isChildDead = (enrolment) => {
              const exitObservation = enrolment.findExitObservation('29238876-fbd8-4f39-b749-edb66024e25e');
              if(!_.isNil(exitObservation) && _.isEqual(exitObservation.getValueWrapper().getValue(), "cbb0969c-c7fe-4ce4-b8a2-670c4e3c5039"))
                return true;
              
              const encounters = enrolment.getEncounters(false);
              const sortedEncounters = _.sortBy(encounters, (encounter) => {
              return _.isNil(encounter.cancelDateTime)? moment().diff(encounter.encounterDateTime) : 
              moment().diff(encounter.cancelDateTime)}); 
              const latestEncounter = _.head(sortedEncounters);
              if(_.isNil(latestEncounter)) return false; 
               
              const cancelObservation = latestEncounter.findCancelEncounterObservation('0066a0f7-c087-40f4-ae44-a3e931967767');
              if(_.isNil(cancelObservation)) return false;
              return _.isEqual(cancelObservation.getValueWrapper().getValue(), "cbb0969c-c7fe-4ce4-b8a2-670c4e3c5039")
            };
        
        return params.db.objects('Individual')
                .filtered(`voided = false`)
                .filter(individual => _.some(individual.enrolments, enrolment => enrolment.program.name === 'Child' && isChildDead(enrolment)));
        }
        
        'use strict';
        ({params, imports}) => {
        
        return params.db.objects('Individual')
                .filtered(`subquery(enrolments, $enrolment, $enrolment.program.name == "Child" and subquery(programExitObservations, $exitObservation, $exitObservation.concept.uuid ==  "29238876-fbd8-4f39-b749-edb66024e25e" and ( $exitObservation.valueJSON ==  '{"answer":"cbb0969c-c7fe-4ce4-b8a2-670c4e3c5039"}')  ).@count > 0 ).@count > 0 OR subquery(enrolments.encounters, $encounter, $encounter.voided == false and subquery(cancelObservations, $cancelObservation, $cancelObservation.concept.uuid ==  "0066a0f7-c087-40f4-ae44-a3e931967767" and ( $cancelObservation.valueJSON ==  '{"answer":"cbb0969c-c7fe-4ce4-b8a2-670c4e3c5039"}')  ).@count > 0 ).@count > 0`)
                .filter(ind => ind.voided == false)
        };
        
        Moving to JS since realm query iterates through all encounters which can be avoided in JS
        In this cases since the intention is to find if child is dead, hence it can be assumed to be captured in the last encounter or in program exit form based on the domain knowledge
        
        
    • Please also refer to Filter based on a custom observation value expression pattern above, before using this. Consider another use case, where observations of numeric concepts need to be compared. This is not possible to achieve via realm query since the solution would involve the need for JSON parsing of the stored observation. Hence JS logic is appropriate here. (refer below code example)

      • Find children with birth weight less than 2. Birth weight is captured in enrolment
        
        'use strict';
        ({params, imports}) => {
            const isLowBirthWeight = (enrolment) => {
                const obs = enrolment.findObservation('Birth Weight');
                return obs ? obs.getReadableValue() <= 2 : false;
            };
            return params.db.objects('Individual')
                .filtered(`voided = false and SUBQUERY(enrolments, $enrolment, $enrolment.program.name = 'Child' and $enrolment.programExitDateTime = null and $enrolment.voided = false).@count > 0`)
                .filter((individual) => _.some(individual.enrolments, enrolment => enrolment.program.name === 'Child' && _.isNil(enrolment.programExitDateTime) && !enrolment.voided && isLowBirthWeight(enrolment)))
        };
        
        'use strict';
        ({params, imports}) => {
            const isLowBirthWeight = (enrolment) => {
                const obs = enrolment.findLatestObservationInEntireEnrolment('Birth Weight');
                return obs ? obs.getReadableValue() <= 2 : false;
            };
            return params.db.objects('Individual')
                .filtered(`SUBQUERY(enrolments, $enrolment, $enrolment.program.name = 'Child' and $enrolment.programExitDateTime = null and $enrolment.voided = false and SUBQUERY($enrolment.observations, $observation, $observation.concept.uuid = 'c82cd1c8-d0a9-4237-b791-8d64e52b6c4a').@count > 0 and voided = false).@count > 0`)
                .filter((individual) => individual.voided === false && _.some(individual.enrolments, enrolment => enrolment.program.name === 'Child' && isLowBirthWeight(enrolment)))
        };
        
        Moving to realm query for checking birth weight was not possible. If it were a equals comparison it can be achieved using 'CONTAINS' in realm
        
  • But in cases where time complexity is the same for both cases, writing realm queries would be efficient to achieve the purpose. (refer below code example). Also refer to Filter based on a custom observation value expression pattern above.

    • Find 13 months children who are completely immunised
      
      'use strict';
      ({params, imports}) => {        
         return params.db.objects('Individual')
              .filtered(`voided = false and SUBQUERY(enrolments, $enrolment, $enrolment.program.name = 'Child' and $enrolment.programExitDateTime = null and $enrolment.voided = false and SUBQUERY(checklists, $checklist, SUBQUERY(items, $item, ($item.detail.concept.name = 'BCG' OR $item.detail.concept.name = 'Polio 0' OR $item.detail.concept.name = 'Polio 1' OR $item.detail.concept.name = 'Polio 2' OR $item.detail.concept.name = 'Polio 3' OR $item.detail.concept.name = 'Pentavalent 1' OR $item.detail.concept.name = 'Pentavalent 2' OR $item.detail.concept.name = 'Pentavalent 3' OR $item.detail.concept.name = 'Measles 1' OR $item.detail.concept.name = 'Measles 2' OR $item.detail.concept.name = 'FIPV 1' OR $item.detail.concept.name = 'FIPV 2' OR $item.detail.concept.name = 'Rota 1' OR $item.detail.concept.name = 'Rota 2') and $item.completionDate <> nil).@count = 14).@count > 0).@count > 0`)
              .filter(individual => individual.getAgeInMonths() >= 13)     
      };
      
      'use strict';
      ({params, imports}) => {
          const isChildGettingImmunised = (enrolment) => {
              if (enrolment.hasChecklist) {
                  const vaccineToCheck = ['BCG', 'Polio 0', 'Polio 1', 'Polio 2', 'Polio 3', 'Pentavalent 1', 'Pentavalent 2', 'Pentavalent 3', 'Measles 1', 'Measles 2', 'FIPV 1', 'FIPV 2', 'Rota 1', 'Rota 2'];
                  const checklist = _.head(enrolment.getChecklists());
                  return _.chain(checklist.items)
                      .filter(({detail}) => _.includes(vaccineToCheck, detail.concept.name))
                      .every(({completionDate}) => !_.isNil(completionDate))
                      .value();
              }
              return false;
          };
      
          return params.db.objects('Individual')
              .filtered(`SUBQUERY(enrolments, $enrolment, $enrolment.program.name = 'Child' and $enrolment.programExitDateTime = null and $enrolment.voided = false).@count > 0`)
              .filter((individual) => individual.voided === false && individual.getAgeInMonths() >= 13 && _.some(individual.enrolments, enrolment => enrolment.program.name === 'Child' && isChildGettingImmunised(enrolment)))
      };
      
      Moving to realm query since no of children with age < 13 months were less
      
  • In most cases, filtering as much as possible using realm queries (for cases like voided checks) and then doing JS filtering on top of it if needed, would be appropriate. (refer the below code example)

    • Find dead children using concept value captured in encounter cancel or program exit form.
      
      'use strict';
      ({params, imports}) => { 
         const moment = imports.moment;
      
         const isChildDead = (enrolment) => {
            const exitObservation = enrolment.findExitObservation('29238876-fbd8-4f39-b749-edb66024e25e');
            if(!_.isNil(exitObservation) && _.isEqual(exitObservation.getValueWrapper().getValue(), "cbb0969c-c7fe-4ce4-b8a2-670c4e3c5039"))
              return true;
            
            const encounters = enrolment.getEncounters(false);
            const sortedEncounters = _.sortBy(encounters, (encounter) => {
            return _.isNil(encounter.cancelDateTime)? moment().diff(encounter.encounterDateTime) : 
            moment().diff(encounter.cancelDateTime)}); 
            const latestEncounter = _.head(sortedEncounters);
            if(_.isNil(latestEncounter)) return false; 
             
            const cancelObservation = latestEncounter.findCancelEncounterObservation('0066a0f7-c087-40f4-ae44-a3e931967767');
            if(_.isNil(cancelObservation)) return false;
            return _.isEqual(cancelObservation.getValueWrapper().getValue(), "cbb0969c-c7fe-4ce4-b8a2-670c4e3c5039")
          };
      
      return params.db.objects('Individual')
              .filtered(`voided = false`)
              .filter(individual => _.some(individual.enrolments, enrolment => enrolment.program.name === 'Child' && isChildDead(enrolment)));
      }
      
      'use strict';
      ({params, imports}) => {
      
      return params.db.objects('Individual')
              .filtered(`subquery(enrolments, $enrolment, $enrolment.program.name == "Child" and subquery(programExitObservations, $exitObservation, $exitObservation.concept.uuid ==  "29238876-fbd8-4f39-b749-edb66024e25e" and ( $exitObservation.valueJSON ==  '{"answer":"cbb0969c-c7fe-4ce4-b8a2-670c4e3c5039"}')  ).@count > 0 ).@count > 0 OR subquery(enrolments.encounters, $encounter, $encounter.voided == false and subquery(cancelObservations, $cancelObservation, $cancelObservation.concept.uuid ==  "0066a0f7-c087-40f4-ae44-a3e931967767" and ( $cancelObservation.valueJSON ==  '{"answer":"cbb0969c-c7fe-4ce4-b8a2-670c4e3c5039"}')  ).@count > 0 ).@count > 0`)
              .filter(ind => ind.voided == false);
      };
      
      Moving to JS since realm query iterates through all encounters which can be avoided in JS
      In this cases since the intention is to find if child is dead it can be assumed to be captured in the last encounter or in program exit form based on the domain knowledge
      
      

Also check - https://avni.readme.io/docs/writing-rules#using-paramsdb-object-when-writing-rules

DEPRECATED. Use Concept UUIDs instead of their names for comparison

Please check - Filter based on a custom observation value expression pattern above.

Though not much performance improvement, using concept uuids(for comparing with concept answers), instead of getting its readable values did provide minor improvement(in seconds) when need to iterate through thousands of rows. (refer below code example)

  • Find children with congential abnormality based on values of certain concepts
    
    'use strict';
    ({params, imports}) => {
        const isChildCongenitalAnamoly = (enrolment) => {
           const _ = imports.lodash;
        
           const encounter = enrolment.lastFulfilledEncounter('Child PNC'); 
           if(_.isNil(encounter)) return false; 
           
           const obs1 = encounter.findObservation("Is the infant's mouth cleft pallet seen?");
           const condition2 = obs1 ? obs1.getValueWrapper().getValue() === '3a9fe9a1-a866-47ed-b75c-c0071ea22d97' : false;
             
           const obs2 = encounter.findObservation('Is there visible tumor on back or on head of infant?');
           const condition3 = obs2 ? obs2.getValueWrapper().getValue() === '3a9fe9a1-a866-47ed-b75c-c0071ea22d97' : false;
             
           const obs3 = encounter.findObservation("Is foam coming from infant's mouth continuously?");
           const condition4 = obs3 ? obs3.getValueWrapper().getValue() === '3a9fe9a1-a866-47ed-b75c-c0071ea22d97' : false;
                      
             return condition2 || condition3 || condition4;
        };
        
        const isChildCongenitalAnamolyReg = (individual) => {
             const obs = individual.findObservation('Has any congenital abnormality?');
             return obs ? obs.getValueWrapper().getValue() === '3a9fe9a1-a866-47ed-b75c-c0071ea22d97' : false;
        };
        
        return params.db.objects('Individual')
            .filtered(`voided = false and SUBQUERY(enrolments, $enrolment, $enrolment.program.name = 'Child' and $enrolment.programExitDateTime = null and $enrolment.voided = false).@count > 0`)
            .filter((individual) => (isChildCongenitalAnamolyReg(individual) || 
                _.some(individual.enrolments, enrolment => enrolment.program.name === 'Child' && _.isNil(enrolment.programExitDateTime) && !enrolment.voided && isChildCongenitalAnamoly(enrolment) )) )
    };
    
    'use strict';
    ({params, imports}) => {
        const isChildCongenitalAnamoly = (enrolment) => {
             
             const obs1 = enrolment.findLatestObservationInEntireEnrolment("Is the infant's mouth cleft pallet seen?");
             const condition2 = obs1 ? obs1.getReadableValue() === 'Yes' : false;
             
         const obs2 = enrolment.findLatestObservationInEntireEnrolment('Is there visible tumor on back or on head of infant?');
             const condition3 = obs2 ? obs2.getReadableValue() === 'Yes' : false;
             
             const obs3 = enrolment.findLatestObservationInEntireEnrolment("Is foam coming from infant's mouth continuously?");
             const condition4 = obs3 ? obs3.getReadableValue() === 'Yes' : false;
                      
             return condition2 || condition3 || condition4;
        };
        
        const isChildCongenitalAnamolyReg = (individual) => {
             const obs = individual.getObservationReadableValue('Has any congenital abnormality?');
             return obs ? obs === 'Yes' : false;
        };
        
        return params.db.objects('Individual')
            .filtered(`SUBQUERY(enrolments, $enrolment, $enrolment.program.name = 'Child' and $enrolment.programExitDateTime = null and $enrolment.voided = false).@count > 0`)
            .filter((individual) => individual.voided === false && (isChildCongenitalAnamolyReg(individual) || 
                _.some(individual.enrolments, enrolment => enrolment.program.name === 'Child' && isChildCongenitalAnamoly(enrolment) )) )
    };
    
    Use concept uuid instead of readableValue to compare and check for value only in specific encounter type where the concept was used
    

Nested Report Cards

Frequently there are cases where across report cards very similar logic is used and only a value used for comparison, changes. Eg: in one of our partner organisations, we load 'Total SAM children' and 'Total MAM children'. For rendering each takes around 20-30s. And hence the dashboard nos doesn't load until both the report card results are calculated and it makes the user to wait for a minute. If the logic is combined, we can render the results in 30s since it would need only retrieval from db and iterating once.
The above kind of scenarios also lead to code duplication across report cards and when some requirement changes, then the change needs to be done in both.

In-order to handle such scenarios, we recommend using the Nested Report Card. This is a non-standard report card, which has the ability to show upto a maximum of 9 report cards, based on a single Query's response.

The query can return an object with "reportCards" property, which holds within it an array of objets with properties, { cardName: 'nested-i', cardColor: '#123456', textColor: '#654321', primaryValue: '20', secondaryValue: '(5%)', lineListFunction: () => {/*Do something*/} }. DB instance is passed using the params and useful libraries like lodash and moment are available in the imports parameter of the function.

'use strict';
({params, imports}) => {
    /*
    Business logic
    */
    return {reportCards: \[
        {
            cardName: 'nested-i',
            cardColor: '#123456',
            textColor: '#654321',
            primaryValue: '20',
            secondaryValue: '(5%)',
            lineListFunction: () => {
                /*Do something*/
            }
        },
        {
            cardName: 'nested-i+1',
            cardColor: '#123456',
            textColor: '#654321',
            primaryValue: '20',
            secondaryValue: '(5%)',
            lineListFunction: () => {
                /*Do something*/
            }
        }
    ]
	}
};
- primaryValue
- secondaryValue
- lineListFunction
- cardName
- cardColor
- textColor
// Documentation - https://docs.mongodb.com/realm-legacy/docs/javascript/latest/index.html#queries

'use strict';
({params, imports}) => {
const _ = imports.lodash;
const moment = imports.moment;

const substanceUseDue = (enrolment) => {
    const substanceUseEnc = enrolment.scheduledEncountersOfType('Record Substance use details');
    
    const substanceUse = substanceUseEnc
    .filter((e) => moment().isSameOrAfter(moment(e.earliestVisitDateTime)) && e.cancelDateTime === null && e.encounterDateTime === null );
    
    return substanceUse.length > 0 ? true : false;
    
    };
const indList = params.db.objects('Individual')
        .filtered(`SUBQUERY(enrolments, $enrolment, $enrolment.program.name = 'Substance use' and $enrolment.programExitDateTime = null and $enrolment.voided = false and SUBQUERY($enrolment.encounters, $encounter, $encounter.encounterType.name = 'Record Substance use details' and $encounter.voided = false ).@count > 0 and voided = false).@count > 0`)
        .filter((individual) => _.some(individual.enrolments, enrolment => substanceUseDue(enrolment)
        )); 
        
const includingVoidedLength = indList.length;
const excludingVoidedLength = 6;  
const llf1 = () => { return params.db.objects('Individual')
        .filtered(`SUBQUERY(enrolments, $enrolment, $enrolment.program.name = 'Substance use' and $enrolment.programExitDateTime = null and $enrolment.voided = false and SUBQUERY($enrolment.encounters, $encounter, $encounter.encounterType.name = 'Record Substance use details' and $encounter.voided = false ).@count > 0 and voided = false).@count > 0`)
        .filter((individual) => _.some(individual.enrolments, enrolment => substanceUseDue(enrolment)
        ));    
        };
           

return {reportCards: [{
      cardName: 'nested 1',
      textColor: '#bb34ff',
      primaryValue: includingVoidedLength,   
      secondaryValue: null,
      lineListFunction: llf1
  },
  {
      cardName: 'nested 2',
      cardColor: '#ff34ff',
      primaryValue: excludingVoidedLength,   
      secondaryValue: null,
      lineListFunction: () => {return params.db.objects('Individual')
        .filtered(`SUBQUERY(enrolments, $enrolment, $enrolment.program.name = 'Substance use' and $enrolment.programExitDateTime = null and $enrolment.voided = false and SUBQUERY($enrolment.encounters, $encounter, $encounter.encounterType.name = 'Record Substance use details' and $encounter.voided = false ).@count > 0 and voided = false).@count > 0`)
        .filter((individual) => individual.voided === false  && _.some(individual.enrolments, enrolment => substanceUseDue(enrolment)
        ));}
  }]}
};

Screenshot of Nested Custom Dashboard Report Card Edit screen on Avni Webapp

Screenshot of Nested Report Cards in Custom Dashboard in Avni Client

Note: If there is a mismatch between the count of nested report cards configured and the length of reportCards property returned by the query response, then we show an appropriate error message on all Nested Report Cards corresponding to the Custom Report Card.


Default Dashboard and Report Cards

Starting in release 10.0, any newly created organisation will have a default dashboard created with the following sections, standard cards and filters.

Default Dashboard (Filters: 'Subject Type' and 'As On Date')

  1. Visit Details Section
    1. Scheduled Visits Card
    2. Overdue Visits Card
  2. Recent Statistics Section
    1. Recent Registrations Card (Recent duration filter configured as - 1 day)
    2. Recent Enrolments Card (Recent duration filter configured as - 1 day)
    3. Recent Visits Card (Recent duration filter configured as - 1 day)
  3. Registration Overview Section
    1. Total Card

This default dashboard will also be assigned as Primary dashboard on the 'Everyone' user group.

Reference screen-shots of Avni-Client Custom Dashboard with Approvals ReportCards and Location filter

Default state of Approvals Report Cards without any filter applied

Default state of Approvals Report Cards without any filter applied


Custom Dashboards filter page

Custom Dashboards filter page


State of Approvals Report Cards after the Location filter was applied

State of Approvals Report Cards after the Location filter was applied