Skip to content

Bug: lastYear.views uses rolling month windows instead of calendar months #1000

@foreverUA

Description

@foreverUA

Reporter: foreverUA (forever@poduryan.com)

Summary

The /api/v2/links/{id}/stats endpoint returns lastYear.views array with monthly view counts that are not aligned to calendar months. Visits are bucketed using differenceInMonths from date-fns, which creates rolling windows anchored to the current day-of-month, rather than proper calendar month boundaries.

Root cause

In server/utils/utils.js:

function getDifferenceFunction(type) {
  if (type === "lastYear") return differenceInMonths; // <-- should be differenceInCalendarMonths
}

differenceInMonths returns truncated full months between two dates. This means visits that occurred after the current day-of-month get shifted into the adjacent (more recent) bucket.

Example

If today is April 10, and a visit occurred on February 25:

  • differenceInMonths(April 10, Feb 25) = 1 (only 1 full month has passed: Feb 25 → Mar 25)
  • index = 11 - 1 = 10 → this bucket represents "March" in a calendar view
  • But the visit actually happened in February

With differenceInCalendarMonths(April 10, Feb 25) = 2, the visit would correctly land at index = 11 - 2 = 9February.

Impact

The magnitude of the error depends on the day of the month when the API is called:

API called on % of previous month's visits misplaced
1st ~96%
10th ~64%
15th ~46%
25th ~11%
28th ~0%

All misplaced visits shift forward by exactly 1 month. This makes lastYear.views unreliable for any calendar-month reporting.

Proposed fix

One-line change in server/utils/utils.js:

- if (type === "lastYear") return differenceInMonths;
+ if (type === "lastYear") return differenceInCalendarMonths;

And add the import:

- const { differenceInMonths, ... } = require("date-fns");
+ const { differenceInMonths, differenceInCalendarMonths, ... } = require("date-fns");

Note: The same issue likely affects lastMonth (which uses differenceInDays — though differenceInDays is less ambiguous since it rounds down full 24h periods, it could still shift daily buckets by 1 depending on timezone/time-of-day).

Environment

  • Self-hosted Kutt instance
  • API v2
  • Verified by comparing raw lastYear.views array against known campaign launch dates across 7 links

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions