Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
"node": "20.8.0"
},
"packageManager": "[email protected]",
"version": "1.51.0-web-3830-pregnancy-ranges.3",
"version": "1.50.0-WEB-3841-pdf-bolus-limit.1",
"description": "Tidepool data visualization for diabetes device data.",
"keywords": [
"data visualization"
Expand Down Expand Up @@ -65,7 +65,7 @@
"moment": "2.29.4",
"moment-timezone": "0.5.43",
"parse-svg-path": "0.1.2",
"pdfkit": "0.13.0",
"pdfkit": "0.15.0",
"process": "0.11.10",
"prop-types": "15.8.1",
"react": "16.14.0",
Expand Down
54 changes: 44 additions & 10 deletions src/modules/print/DailyPrintView.js
Original file line number Diff line number Diff line change
Expand Up @@ -241,6 +241,35 @@ class DailyPrintView extends PrintView {
return this;
}

/**
* Counts the number of lines needed to render bolus entries, respecting a maximum line limit.
* Determines which boluses can be rendered within the limit and whether an ellipsis is needed.
*
* @param {Array<Object>} boluses - Array of bolus objects to be rendered.
* @param {number} [maxLines=50] - Maximum number of lines allowed for rendering.
* @returns {{ count: number, bolusesToRender: Array<Object>, needsEllipsis: boolean }}
* An object containing:
* - count: Total number of lines used.
* - bolusesToRender: Array of bolus objects that fit within the line limit.
* - needsEllipsis: Whether additional boluses exist beyond the limit.
*/
countBolusLinesWithLimit(boluses, maxLines = 50) {
let lineCount = 0;
let needsEllipsis = false;
const bolusesToRender = [];
for (let i = 0; i < boluses.length; ++i) {
const bolus = boluses[i];
const linesForBolus = (bolus.extended != null || bolus.expectedExtended != null) ? 2 : 1;
if (lineCount + linesForBolus > maxLines) {
needsEllipsis = true;
break;
}
bolusesToRender.push(bolus);
lineCount += linesForBolus;
}
return { count: lineCount, bolusesToRender, needsEllipsis };
}

calculateDateChartHeight({ data, date }) {
this.doc.fontSize(this.smallFontSize);
const lineHeight = this.doc.currentLineHeight() * 1.25;
Expand All @@ -252,14 +281,8 @@ class DailyPrintView extends PrintView {
const maxBolusStack = _.max(_.map(
_.keys(threeHrBinnedBoluses),
(key) => {
const totalLines = _.reduce(threeHrBinnedBoluses[key], (lines, insulinEvent) => {
const bolus = getBolusFromInsulinEvent(insulinEvent);
if (bolus.extended || bolus.expectedExtended) {
return lines + 2;
}
return lines + 1;
}, 0);
return totalLines;
const boluses = _.sortBy(threeHrBinnedBoluses[key].map(getBolusFromInsulinEvent), 'normalTime');
return this.countBolusLinesWithLimit(boluses, 50).count;
}
));

Expand Down Expand Up @@ -898,7 +921,9 @@ class DailyPrintView extends PrintView {
},
};
}(this.doc));
_.each(_.sortBy(binOfBoluses, 'normalTime'), (bolus) => {
const sortedBoluses = _.sortBy(binOfBoluses, 'normalTime');
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I see that you're sorting the boluses here prior to passing to countBolusLinesWithLimit, which makes sense.

However, you haven't done the same in when passing the 3h binned boluses in calculateDateChartHeight earlier.

I'm thinking it's possible that the earlier height is then calculated based on a different set of boluses that get rendered here.

i.e. perhaps the first 50 (of 60) when called within calculateDateChartHeight are normal boluses, but when you pass the sorted boluses in here, there are a few extended boluses that needed more lines than what was allotted for height.

Should we also pass sorted boluses in the earlier calls to countBolusLinesWithLimit?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We can add the sort, for sure, but I can't think of a pathological case for a mismatch causing serious issues as is. If the first unsorted 50 of 60 happen to be 1 line boluses, we'd still end up counting at least up to the 50 line limit and returning the "count" there as 50. When it comes to this sorted set, we actually get the list of boluses to render also taking into account the 50 line limit. So the height count will always take into account the whole set up to the 50 line limit and the rendering will only render the first X number of boluses that will fit into a 50 line limit.

The only mismatch I can come up with would be potentially landing on a "count" returned for the height calculation of 49 instead of 50 by hitting an extended bolus and returning early on that count, but given that calculateDateChartHeight is just used for charts-per-page layout, I don't think that the 1 line difference could ever result in the wrong number of charts on a page (given proximity to the maximum).

For the sake of consistency, adding the sort is a good call though.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah, I get it. This whole PR is based on an unrealistic edge case, and the sort issue has a basically zero chance of happening.

You can put it for re-review and skip the sorting if you'd prefer.

Copy link
Member Author

@krystophv krystophv Aug 8, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I added the sort (ecce2ff) 😄 , will put it up for re-review. And my reply was at least as much just me re-stating the logic to convince myself that I hadn't introduced a new edge case as it was trying to convince anyone else.

const { bolusesToRender, needsEllipsis } = this.countBolusLinesWithLimit(sortedBoluses, 50);
_.each(bolusesToRender, (bolus) => {
const displayTime = formatLocalizedFromUTC(bolus.normalTime, this.timePrefs, 'h:mma')
.slice(0, -1);
this.doc.text(
Expand All @@ -911,7 +936,7 @@ class DailyPrintView extends PrintView {
{ align: 'right' }
);

if (bolus.extended != null) {
if (bolus.extended != null || bolus.expectedExtended != null) {
Copy link

Copilot AI Aug 7, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This condition check is duplicated from line 262 in the countBolusLinesWithLimit method. Consider extracting this logic into a helper method like isExtendedBolus(bolus) to avoid code duplication and improve maintainability.

Copilot uses AI. Check for mistakes.

const normalPercentage = getNormalPercentage(bolus);
const extendedPercentage = getExtendedPercentage(bolus);
const durationOpts = { ascii: true };
Expand All @@ -927,6 +952,15 @@ class DailyPrintView extends PrintView {
}
yPos.update();
});
if (needsEllipsis) {
this.doc.text(
'…',
groupXPos,
yPos.current(),
{ indent: 2, width: groupWidth }
);
yPos.update();
}
});

return this;
Expand Down
90 changes: 90 additions & 0 deletions test/modules/print/DailyPrintView.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -725,6 +725,50 @@ describe('DailyPrintView', () => {

sinon.assert.callCount(Renderer.doc.text, expectedTextCallCount);
});

it('should truncate bolus details to 50 lines and add an ellipsis as the 51st entry', () => {
// 25 extended boluses (2 lines each, 3 text() calls each) = 50 lines, 75 text() calls
const boluses = _.times(25, i => ({ normalTime: i * 1000, threeHrBin: 0, extended: 1 }));
// Add 1 more normal bolus, which should not be rendered
boluses.push({ normalTime: 99999, threeHrBin: 0 });
const chart = {
bolusDetailPositions: [0],
bolusDetailWidths: [100],
bolusScale: { range: () => [0, 100] },
data: { bolus: boluses },
timePrefs: {},
};
const RendererLocal = new DailyPrintView(new Doc({ margin: MARGIN }), { data: { current: { data: {} } }, dataByDate: {} }, opts);
sinon.stub(RendererLocal, 'timePrefs').value({});
RendererLocal.renderBolusDetails(chart);
// 25 extended boluses * 3 text calls = 75, plus 1 ellipsis
const expectedMinCalls = 75 + 1;
sinon.assert.callCount(RendererLocal.doc.text, expectedMinCalls);
sinon.assert.calledWith(RendererLocal.doc.text, '…');
});

it('should not add an ellipsis if 50 or fewer lines', () => {
// 24 extended boluses (2 lines each) = 48 lines, 2 normal boluses = 2 lines, total 50
const boluses = [
..._.times(24, i => ({ normalTime: i * 1000, threeHrBin: 0, extended: 1 })),
{ normalTime: 99998, threeHrBin: 0 },
{ normalTime: 99999, threeHrBin: 0 },
];
const chart = {
bolusDetailPositions: [0],
bolusDetailWidths: [100],
bolusScale: { range: () => [0, 100] },
data: { bolus: boluses },
timePrefs: {},
};
const RendererLocal = new DailyPrintView(new Doc({ margin: MARGIN }), { data: { current: { data: {} } }, dataByDate: {} }, opts);
sinon.stub(RendererLocal, 'timePrefs').value({});
RendererLocal.renderBolusDetails(chart);
// 24*3 + 2*2 = 76 calls
const expectedMinCalls = 76;
sinon.assert.callCount(RendererLocal.doc.text, expectedMinCalls);
sinon.assert.neverCalledWith(RendererLocal.doc.text, '…');
});
});

describe('renderBasalPaths', () => {
Expand Down Expand Up @@ -870,4 +914,50 @@ describe('DailyPrintView', () => {
sinon.assert.calledWith(Renderer.doc.text, 'manual & automated');
});
});

describe('countBolusLinesWithLimit', () => {
it('should count lines for normal boluses only', () => {
const RendererLocal = new DailyPrintView(new Doc({ margin: MARGIN }), { data: { current: { data: {} } }, dataByDate: {} }, opts);
const boluses = _.times(10, i => ({ normalTime: i * 1000 }));
const result = RendererLocal.countBolusLinesWithLimit(boluses, 50);
expect(result.count).to.equal(10);
expect(result.bolusesToRender.length).to.equal(10);
expect(result.needsEllipsis).to.be.false;
});

it('should count lines for extended boluses', () => {
const RendererLocal = new DailyPrintView(new Doc({ margin: MARGIN }), { data: { current: { data: {} } }, dataByDate: {} }, opts);
const boluses = _.times(5, i => ({ normalTime: i * 1000, extended: 1 }));
const result = RendererLocal.countBolusLinesWithLimit(boluses, 50);
expect(result.count).to.equal(10);
expect(result.bolusesToRender.length).to.equal(5);
expect(result.needsEllipsis).to.be.false;
});

it('should count lines for mixed boluses', () => {
const RendererLocal = new DailyPrintView(new Doc({ margin: MARGIN }), { data: { current: { data: {} } }, dataByDate: {} }, opts);
const boluses = [
{ normalTime: 0 },
{ normalTime: 1, extended: 1 },
{ normalTime: 2 },
{ normalTime: 3, expectedExtended: 1 },
];
const result = RendererLocal.countBolusLinesWithLimit(boluses, 50);
expect(result.count).to.equal(6);
expect(result.bolusesToRender.length).to.equal(4);
expect(result.needsEllipsis).to.be.false;
});

it('should stop at maxLines and set needsEllipsis if exceeded', () => {
const RendererLocal = new DailyPrintView(new Doc({ margin: MARGIN }), { data: { current: { data: {} } }, dataByDate: {} }, opts);
// 25 extended boluses (2 lines each) = 50 lines
const boluses = _.times(25, i => ({ normalTime: i * 1000, extended: 1 }));
// Add 1 more normal bolus, which should not be rendered
boluses.push({ normalTime: 99999 });
const result = RendererLocal.countBolusLinesWithLimit(boluses, 50);
expect(result.count).to.equal(50);
expect(result.bolusesToRender.length).to.equal(25);
expect(result.needsEllipsis).to.be.true;
});
});
});
31 changes: 29 additions & 2 deletions yarn.lock
Original file line number Diff line number Diff line change
Expand Up @@ -4238,7 +4238,7 @@ __metadata:
object-invariant-test-helper: 0.1.1
optional: 0.1.4
parse-svg-path: 0.1.2
pdfkit: 0.13.0
pdfkit: 0.15.0
plotly.js-basic-dist-min: 2.26.2
postcss: 8.4.31
postcss-calc: 9.0.1
Expand Down Expand Up @@ -7184,6 +7184,13 @@ __metadata:
languageName: node
linkType: hard

"crypto-js@npm:^4.2.0":
version: 4.2.0
resolution: "crypto-js@npm:4.2.0"
checksum: f051666dbc077c8324777f44fbd3aaea2986f198fe85092535130d17026c7c2ccf2d23ee5b29b36f7a4a07312db2fae23c9094b644cc35f7858b1b4fcaf27774
languageName: node
linkType: hard

"crypto-random-string@npm:^2.0.0":
version: 2.0.0
resolution: "crypto-random-string@npm:2.0.0"
Expand Down Expand Up @@ -11103,6 +11110,13 @@ __metadata:
languageName: node
linkType: hard

"jpeg-exif@npm:^1.1.4":
version: 1.1.4
resolution: "jpeg-exif@npm:1.1.4"
checksum: a8693a7eeb6c6572ca39acc8bbaf4bac1eea1331a26ec7d460410c0c7aefcb944bbc6c31d3c4649a308eea9da89ee4d38e35fe2f2604e4bf2ed09abd600cff0b
languageName: node
linkType: hard

"js-tokens@npm:^3.0.0 || ^4.0.0, js-tokens@npm:^4.0.0":
version: 4.0.0
resolution: "js-tokens@npm:4.0.0"
Expand Down Expand Up @@ -13541,7 +13555,20 @@ __metadata:
languageName: node
linkType: hard

"pdfkit@npm:0.13.0, pdfkit@npm:>=0.8.1":
"pdfkit@npm:0.15.0":
version: 0.15.0
resolution: "pdfkit@npm:0.15.0"
dependencies:
crypto-js: ^4.2.0
fontkit: ^1.8.1
jpeg-exif: ^1.1.4
linebreak: ^1.0.2
png-js: ^1.0.0
checksum: 044d24f0efd563834bc4c45f12be6e91f98e31d0d664b042d89f35588d3fb985134566280bba6fa29360f61ae1ba62310dccba4b0f102aa448f307c6bbe65bd1
languageName: node
linkType: hard

"pdfkit@npm:>=0.8.1":
version: 0.13.0
resolution: "pdfkit@npm:0.13.0"
dependencies:
Expand Down