Fix date off-by-one in performance heatmap widget
As I had [mentioned on the forums](https://forum.portfolio-performance.info/t/januar-fehlt-in-monatlichen-heatmaps/14219/12), March was missing from the performance heatmap on March 1st (but reappered the next day). This was due to an off-by-one bug, where the data interval was compared to monthly intervals of the form (2021-03-01, 2021-03-31) which did not actually include March 1st, because of the half-open nature of `Interval`s.

In addition to the (trivial) fix, I refactored the representation of months and years in the heatmap widget to use `YearMonth`s and `Year`s, instead of abusing `LocalDate`s such as 2021-01-01 to stand for the year 2021 or the month 2021-01, depending on context.
name.abuchen.portfolio.ui/src/name/abuchen/portfolio/ui/views/dashboard/heatmap/PerformanceHeatmapWidget.java
package name.abuchen.portfolio.ui.views.dashboard.heatmap;

import java.time.LocalDate;
import java.time.Year;
import java.util.Arrays;
import java.util.function.DoubleBinaryOperator;
import java.util.function.ToDoubleFunction;

import name.abuchen.portfolio.model.Dashboard.Widget;
import name.abuchen.portfolio.money.Values;
import name.abuchen.portfolio.snapshot.PerformanceIndex;
import name.abuchen.portfolio.ui.Messages;
import name.abuchen.portfolio.ui.views.dashboard.DashboardData;
import name.abuchen.portfolio.ui.views.dashboard.DataSeriesConfig;
import name.abuchen.portfolio.ui.views.dashboard.ReportingPeriodConfig;
import name.abuchen.portfolio.ui.views.dataseries.DataSeries;
import name.abuchen.portfolio.util.Interval;

public class PerformanceHeatmapWidget extends AbstractHeatmapWidget<Double>
{
    public PerformanceHeatmapWidget(Widget widget, DashboardData data)
    {
        super(widget, data);

        addConfig(new ColorSchemaConfig(this));
        addConfig(new HeatmapOrnamentConfig(this));
        addConfig(new DataSeriesConfig(this, true));
        addConfig(new ExcessReturnDataSeriesConfig(this));
        addConfig(new ExcessReturnOperatorConfig(this));
    }

    @Override
    protected HeatmapModel<Double> build()
    {
        int numDashboardColumns = getDashboardData().getDashboard().getColumns().size();

        Interval interval = get(ReportingPeriodConfig.class).getReportingPeriod().toInterval(LocalDate.now());

        // adapt interval to include the first and last month fully

        Interval calcInterval = Interval.of(
                        interval.getStart().getDayOfMonth() == interval.getStart().lengthOfMonth() ? interval.getStart()
                                        : interval.getStart().withDayOfMonth(1).minusDays(1),
                        interval.getEnd().withDayOfMonth(interval.getEnd().lengthOfMonth()));

        DataSeries dataSeries = get(DataSeriesConfig.class).getDataSeries();
        PerformanceIndex performanceIndex = getDashboardData().calculate(dataSeries, calcInterval);

        // build functions to calculate performance and sum values

        ToDoubleFunction<LocalDate> calculatePerformance = month -> getPerformanceFor(performanceIndex, month);
        ToDoubleFunction<LocalDate> calculateSum = year -> getSumPerformance(performanceIndex, year);

        DataSeries benchmark = get(ExcessReturnDataSeriesConfig.class).getDataSeries();
        if (benchmark != null)
        {
            PerformanceIndex benchmarkIndex = getDashboardData().calculate(benchmark, calcInterval);

            DoubleBinaryOperator operator = get(ExcessReturnOperatorConfig.class).getValue().getOperator();
            calculatePerformance = month -> operator.applyAsDouble( //
                            getPerformanceFor(performanceIndex, month), getPerformanceFor(benchmarkIndex, month));
            calculateSum = year -> operator.applyAsDouble( //
                            getSumPerformance(performanceIndex, year), getSumPerformance(benchmarkIndex, year));
        }

        // build heat map model for actual interval

        Interval actualInterval = performanceIndex.getActualInterval();

        boolean showSum = get(HeatmapOrnamentConfig.class).getValues().contains(HeatmapOrnament.SUM);
        boolean showStandardDeviation = get(HeatmapOrnamentConfig.class).getValues()
                        .contains(HeatmapOrnament.STANDARD_DEVIATION);

        HeatmapModel<Double> model = new HeatmapModel<>(
                        numDashboardColumns == 1 ? Values.Percent : Values.PercentShort);
        model.setCellToolTip(v -> Messages.PerformanceHeatmapToolTip);

        // add header

        addMonthlyHeader(model, numDashboardColumns, showSum, showStandardDeviation, false);

        // build row for each year

        for (Year year : actualInterval.getYears())
        {
            String label = numDashboardColumns > 2 ? String.valueOf(year.getValue() % 100) : String.valueOf(year);
            HeatmapModel.Row<Double> row = new HeatmapModel.Row<>(label);

            // monthly data
            for (LocalDate month = LocalDate.of(year.getValue(), 1, 1); month.getYear() == year
                            .getValue(); month = month.plusMonths(1))
            {
                if (actualInterval.intersects(Interval.of(month, month.plusMonths(1).minusDays(1))))
                    row.addData(calculatePerformance.applyAsDouble(month));
                else
                    row.addData(null);
            }

            // sum
            if (showSum)
                row.addData(calculateSum.applyAsDouble(LocalDate.of(year.getValue(), 1, 1)));

            if (showStandardDeviation)
                row.addData(standardDeviation(row.getDataSubList(0, 12)));

            model.addRow(row);
        }

        // create geometric mean

        if (get(HeatmapOrnamentConfig.class).getValues().contains(HeatmapOrnament.GEOMETRIC_MEAN))
        {
            HeatmapModel.Row<Double> geometricMean = new HeatmapModel.Row<>("x\u0304 geom"); //$NON-NLS-1$
            for (int index = 0; index < model.getHeaderSize(); index++)
                geometricMean.addData(geometricMean(model.getColumnValues(index)));
            model.addRow(geometricMean);
        }

        return model;
    }

    private double getPerformanceFor(PerformanceIndex index, LocalDate month)
    {
        int start = Arrays.binarySearch(index.getDates(), month.minusDays(1));
        // should not happen, but let's be defensive this time
        if (start < 0)
            start = 0;

        int end = Arrays.binarySearch(index.getDates(), month.withDayOfMonth(month.lengthOfMonth()));
        // make sure there is an end index if the binary search returns a
        // negative value (i.e. if the current month is not finished)
        if (end < 0)
        {
            // take the last available date
            end = index.getDates().length - 1;
        }

        return ((index.getAccumulatedPercentage()[end] + 1) / (index.getAccumulatedPercentage()[start] + 1)) - 1;
    }

    private double getSumPerformance(PerformanceIndex index, LocalDate year)
    {
        int start = Arrays.binarySearch(index.getDates(), year.minusDays(1));
        if (start < 0)
            start = 0;

        int end = Arrays.binarySearch(index.getDates(), year.withDayOfYear(year.lengthOfYear()));
        if (end < 0)
            end = index.getDates().length - 1;

        return ((index.getAccumulatedPercentage()[end] + 1) / (index.getAccumulatedPercentage()[start] + 1)) - 1;
    }

}

package name.abuchen.portfolio.ui.views.dashboard.heatmap;

import java.time.LocalDate;
import java.time.Year;
import java.time.YearMonth;
import java.time.temporal.TemporalAdjusters;
import java.util.Arrays;
import java.util.function.DoubleBinaryOperator;
import java.util.function.ToDoubleFunction;

import name.abuchen.portfolio.model.Dashboard.Widget;
import name.abuchen.portfolio.money.Values;
import name.abuchen.portfolio.snapshot.PerformanceIndex;
import name.abuchen.portfolio.ui.Messages;
import name.abuchen.portfolio.ui.views.dashboard.DashboardData;
import name.abuchen.portfolio.ui.views.dashboard.DataSeriesConfig;
import name.abuchen.portfolio.ui.views.dashboard.ReportingPeriodConfig;
import name.abuchen.portfolio.ui.views.dataseries.DataSeries;
import name.abuchen.portfolio.util.Interval;

public class PerformanceHeatmapWidget extends AbstractHeatmapWidget<Double>
{
    public PerformanceHeatmapWidget(Widget widget, DashboardData data)
    {
        super(widget, data);

        addConfig(new ColorSchemaConfig(this));
        addConfig(new HeatmapOrnamentConfig(this));
        addConfig(new DataSeriesConfig(this, true));
        addConfig(new ExcessReturnDataSeriesConfig(this));
        addConfig(new ExcessReturnOperatorConfig(this));
    }

    @Override
    protected HeatmapModel<Double> build()
    {
        int numDashboardColumns = getDashboardData().getDashboard().getColumns().size();

        Interval interval = get(ReportingPeriodConfig.class).getReportingPeriod().toInterval(LocalDate.now());

        // adapt interval to include the first and last month fully

        Interval calcInterval = Interval.of(
                        interval.getStart().getDayOfMonth() == interval.getStart().lengthOfMonth() ? interval.getStart()
                                        : interval.getStart().withDayOfMonth(1).minusDays(1),
                        interval.getEnd().with(TemporalAdjusters.lastDayOfMonth()));

        DataSeries dataSeries = get(DataSeriesConfig.class).getDataSeries();
        PerformanceIndex performanceIndex = getDashboardData().calculate(dataSeries, calcInterval);

        // build functions to calculate performance and sum values

        ToDoubleFunction<YearMonth> calculatePerformance = month -> getPerformanceFor(performanceIndex, month);
        ToDoubleFunction<Year> calculateSum = year -> getSumPerformance(performanceIndex, year);

        DataSeries benchmark = get(ExcessReturnDataSeriesConfig.class).getDataSeries();
        if (benchmark != null)
        {
            PerformanceIndex benchmarkIndex = getDashboardData().calculate(benchmark, calcInterval);

            DoubleBinaryOperator operator = get(ExcessReturnOperatorConfig.class).getValue().getOperator();
            calculatePerformance = month -> operator.applyAsDouble( //
                            getPerformanceFor(performanceIndex, month), getPerformanceFor(benchmarkIndex, month));
            calculateSum = year -> operator.applyAsDouble( //
                            getSumPerformance(performanceIndex, year), getSumPerformance(benchmarkIndex, year));
        }

        // build heat map model for actual interval

        Interval actualInterval = performanceIndex.getActualInterval();

        boolean showSum = get(HeatmapOrnamentConfig.class).getValues().contains(HeatmapOrnament.SUM);
        boolean showStandardDeviation = get(HeatmapOrnamentConfig.class).getValues()
                        .contains(HeatmapOrnament.STANDARD_DEVIATION);

        HeatmapModel<Double> model = new HeatmapModel<>(
                        numDashboardColumns == 1 ? Values.Percent : Values.PercentShort);
        model.setCellToolTip(v -> Messages.PerformanceHeatmapToolTip);

        // add header

        addMonthlyHeader(model, numDashboardColumns, showSum, showStandardDeviation, false);

        // build row for each year

        for (Year year : actualInterval.getYears())
        {
            String label = numDashboardColumns > 2 ? String.valueOf(year.getValue() % 100) : String.valueOf(year);
            HeatmapModel.Row<Double> row = new HeatmapModel.Row<>(label);

            // monthly data
            for (YearMonth month = YearMonth.of(year.getValue(), 1);
                 month.getYear() == year.getValue();
                 month = month.plusMonths(1))
            {
                if (actualInterval.intersects(Interval.of(month.atDay(1).minusDays(1), month.atEndOfMonth())))
                    row.addData(calculatePerformance.applyAsDouble(month));
                else
                    row.addData(null);
            }

            // sum
            if (showSum)
                row.addData(calculateSum.applyAsDouble(year));

            if (showStandardDeviation)
                row.addData(standardDeviation(row.getDataSubList(0, 12)));

            model.addRow(row);
        }

        // create geometric mean

        if (get(HeatmapOrnamentConfig.class).getValues().contains(HeatmapOrnament.GEOMETRIC_MEAN))
        {
            HeatmapModel.Row<Double> geometricMean = new HeatmapModel.Row<>("x\u0304 geom"); //$NON-NLS-1$
            for (int index = 0; index < model.getHeaderSize(); index++)
                geometricMean.addData(geometricMean(model.getColumnValues(index)));
            model.addRow(geometricMean);
        }

        return model;
    }

    private double getPerformanceFor(PerformanceIndex index, YearMonth month)
    {
        int start = Arrays.binarySearch(index.getDates(), month.atDay(1).minusDays(1));
        // should not happen, but let's be defensive this time
        if (start < 0)
            start = 0;

        int end = Arrays.binarySearch(index.getDates(), month.atEndOfMonth());
        // make sure there is an end index if the binary search returns a
        // negative value (i.e. if the current month is not finished)
        if (end < 0)
        {
            // take the last available date
            end = index.getDates().length - 1;
        }

        return ((index.getAccumulatedPercentage()[end] + 1) / (index.getAccumulatedPercentage()[start] + 1)) - 1;
    }

    private double getSumPerformance(PerformanceIndex index, Year year)
    {
        int start = Arrays.binarySearch(index.getDates(), year.atDay(1).minusDays(1));
        if (start < 0)
            start = 0;

        int end = Arrays.binarySearch(index.getDates(), year.atDay(1).with(TemporalAdjusters.lastDayOfYear()));
        if (end < 0)
            end = index.getDates().length - 1;

        return ((index.getAccumulatedPercentage()[end] + 1) / (index.getAccumulatedPercentage()[start] + 1)) - 1;
    }

}

The `.minusDays(1)` here is the bug fix, changing the starting date from 2021-03-01 to 2021-02-28.