Skip to content

feat: add interactive cost calculator to the hosting costs page#2101

Open
jkuester wants to merge 81 commits intomedic:mainfrom
jkuester:cost-calculator
Open

feat: add interactive cost calculator to the hosting costs page#2101
jkuester wants to merge 81 commits intomedic:mainfrom
jkuester:cost-calculator

Conversation

@jkuester
Copy link
Contributor

@jkuester jkuester commented Dec 19, 2025

Description

This is a little present for @mrjones-plip when he returns from the holidays! 🎄

image

It is a first draft of an interactive Hosting Cost estimation tool (forum thread).

It turns out it is pretty easy to add interactive HTML to your Hugo site via a custom shortcode. And, the modern CSS functionality provide very nice layout and visualization so I did not have to resort to any dependencies.

I am pretty happy with the features/layout/functionality of the whole thing, but the tuning of it could use more eyes.

One simplification that I have started with (but which could be easily refactored) is that I have only factored in the costs of 5 example EC2 instances and I try to pick one of those based on load (users * workflows). This approach has obvious limitations, but it was simple enough to let me get started with real world numbers. Definitely open to suggestions on the best way to improve this.

The other things that need more tuning are the various constant value that are used in down-stream computations:

  DISK_COST_PER_GB: 1,
  CONTACTS_PER_PLACE: 5,
  WORKFLOW_YEARLY_DOCS_PER_CONTACT: 12,
  DOCS_PER_GB: 12000,

DISK_COST_PER_GB is a rounded-up estimate from EBS. CONTACTS_PER_PLACE is roughly the "household size", but it gets used to estimate how many ancestor contacts are in the hierarchy tree (given the user input of the population size). Currently the logic assumes a completely even distribution and density of contacts throughout the tree. WORKFLOW_YEARLY_DOCS_PER_CONTACT is by far the most hand-wavy. Basically I need some way to connect how many workflows are being supported by the instance with an estimate of how many reports will be generated per year. So, in this case 12 means that I think we will have an average of 1 report created per month per workflow per contact. This may be way off. DOCS_PER_GB is a rough estimate that I made based on Watchdog data from a large production instance.

Most of this code was written (or heavily influenced) by Claude, but I have carefully reviewed and edited it for maximum maintainability.

License

The software is provided under AGPL-3.0. Contributions to this project are accepted under the same license.

…ed breakdown section. Adjust layout for improved readability.
…te instance load ranges and adjust user count limits for consistency.
…r. Update instance load range for c6g.8xlarge.
@jkuester jkuester requested a review from mrjones-plip March 10, 2026 22:58
@mrjones-plip
Copy link
Contributor

wow! a lot of hard work put in here - thanks. I'm a bit backed up but hope to get to this this week!

@jkuester
Copy link
Contributor Author

Thanks! No rush. 👍 Also forgot to include this link to the raw data and calculations that I collected from Watchdog.

Copy link
Contributor

@mrjones-plip mrjones-plip left a comment

Choose a reason for hiding this comment

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

This is great! Thank so much for tending all my feedback - it just works! I think the optimizations you've made make the tool much easier to read and more accessible for the high level planners that might use it. While I have some suggestions, I don't think they're blocking and will approve now assuming someone will bust out the code review while I'm out next week and we can ship.

I'll be happy to do a deeper dive either if this PR is still open when I get back or on the live code in a separate issue/PR as needed.


The biggie that I didn't have time to dive into the calculations. They feel right, but I didn't do any sanity checks. Before going live we should still have Elijah/Loukman/Philip do this sanity check.

I think we should move the Calculation details section right below the calculator and shift all the other sections as is down so it's followed by Accuracy, prod vs dev etc.. Really, the calculator is what people will be here for and we want to answer questions about how we came to our numbers right away.

It might be nice to have thousandth separators? I suspect that's US centric, so no biggie, but $11,020/yr is so much easier for me to parse than $11020/yr. No biggie if others disagree and we don't add it.

I still think it's weird that when you do something silly like 1.5M people, 1 workflow, 10 users, you have 156k people per user, but with a max of 250. Same for docs per people (370k max of 20k). Maybe remove the small numbers (250 & 20k)? I don't feel strongly, but feel like it might open us up to unneeded scrutiny.

Maybe make this change? Seems simpler, but I don't feel strongly:

Image

I'd also consider removing the base 50GB somewhat like it was before - but - meh - not that big of a deal.

@jkuester
Copy link
Contributor Author

Thanks for the feedback @mrjones-plip! I will have a look at it next week. (Enjoy your week away! 🎉 )

I still think it's weird that when you do something silly like 1.5M people, 1 workflow, 10 users, you have 156k people per user, but with a max of 250. Same for docs per people (370k max of 20k). Maybe remove the small numbers (250 & 20k)? I don't feel strongly, but feel like it might open us up to unneeded scrutiny.

Yeah, I have gone back and forth on this. While the current setup allows you to key in some weird values, I am not sure there are any better alternatives. 🤷 If we locked it down so one slider value depended on the other sliders (have to add more users if you want more pop) then the user might need to flip back and forth adjusting sliders multiple times just to dial in their desired numbers...

Instead I have tried to set reasonable(ish) min/max on the ranges and then I am thinking the "People per user" and "Docs per user" indicators will provide some feedback to the user about how reasonable their setup is. (Basically, let them key in a silly setup, but then show them it is silly.) Happy to iterate on this more, though, if we can come up with something that makes more sense.

@jkuester
Copy link
Contributor Author

It might be nice to have thousandth separators? I suspect that's US centric, so no biggie, but $11,020/yr is so much easier for me to parse than $11020/yr. No biggie if others disagree and we don't add it.

Good call! If there is one thing that JS is good at, it is making it easy to render content in the user's current locale. I just updated the code to format all the number/currency values according to the user's current locale. So, everyone should see the delimiters as they expect.

@jkuester
Copy link
Contributor Author

I still think it's weird that when you do something silly like 1.5M people, 1 workflow, 10 users, you have 156k people per user, but with a max of 250. Same for docs per people (370k max of 20k). Maybe remove the small numbers (250 & 20k)? I don't feel strongly, but feel like it might open us up to unneeded scrutiny.

Reading this again, I think I see your actual point here about the small numbers on the range bars. 😅 I agree that they are actually unnecessary and have the potential to be confusing when your actual value goes outside the range. I will remove them!

@jkuester
Copy link
Contributor Author

I'd also consider removing the base 50GB somewhat like it was before - but - meh - not that big of a deal.

Def open to discussing this more. The main reason I felt it was useful was because especially on the smaller instances, the disk cost estimate was not realistic. As I understand it, on a normal EC2 instance, I think you are billed the same for your root host volume as you are for EBS ($0.08/GB/mo). So, it seemed more accurate to bake this constant in here... 🤷

@jkuester jkuester requested a review from sugat009 March 18, 2026 16:32
@jkuester
Copy link
Contributor Author

jkuester commented Mar 18, 2026

@sugat009 can I get a review of the code implementation? 🙏 This has largely been implemented with Claude, but I have gone through line-by-line to cleanup/review the code and am happy with the quality of the result!

The main focus was to stick to vanilla js/html/css without requiring additional libs.

@jkuester jkuester requested a review from eljhkrr March 18, 2026 16:35
@jkuester
Copy link
Contributor Author

@eljhkrr, when you get the chance, can you help me with two things?

  1. The calculator is currently based on values which I calculated from an uninformed sampling of instance data from https://watchdog.app.medicmobile.org (specifically from instances where we had Node Exporter data). Could you recommend to me (maybe in Slack) a more informed list of production instances (~10) that I could use to base the averages off of? A range of big and small instances would be great, but I think the most important thing would be that they are on 5.x and that we have access to Node Exporter data. (Also, I think a number of MOH instances might be located on the same host. It is okay to include those as long as you can tell me which ones map to which host (so I can properly account for it when mapping to the Node Exporter data for the host...)
  2. Can you spin up the calculator and just give it a sanity check. Is the provided data useful? Are there any missing parameters that are essential to include? Does the reported estimates pass the "smell test"? (Because of the variability between CHT instances it is going to be hard to exactly match what we see for a particular production instance, but I am trying to get as close as reasonably possible...)

Copy link
Member

@sugat009 sugat009 left a comment

Choose a reason for hiding this comment

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

A few nits! Nothing major.

const deploymentAge = Number.parseFloat(els.deploymentAgeValue.value);
const workflowCount = Number.parseFloat(els.workflowCount.value);
const populationCount = Number.parseFloat(els.populationCount.value);
const userCount = Number.parseFloat(els.userCountInput.value);
Copy link
Member

Choose a reason for hiding this comment

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

parseFloat vs parseInt inconsistency for userCount

userCount is parsed with Number.parseFloat here (line 55), but with Number.parseInt in updateOutputElements (line 103). Since userCount is always an integer (slider step="10", type="number"), these will usually produce the same result — but if a user types a decimal, they will diverge.

Use Number.parseInt consistently for all integer quantities (workflowCount, populationCount, userCount), or use Math.round(Number.parseFloat(...)) for all of them.

{{< callout type="warning" >}}
Be sure to read the [Accuracy section](/hosting/cht/costs/#accuracy) so you understand what the costs on these page mean for your deployment.
{{< /callout >}}
{{< cost-calculator >}}
Copy link
Member

Choose a reason for hiding this comment

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

Minor style guide fixes

A few small items per the CHT style guide:

  1. Double spaces after periods on lines 88 and 90 — should be single spaces
  2. e.g. on line 90 → use "such as" (style guide: "For example" not "e.g.")
  3. Extra space in heading ## Production vs Development (line 92) — two spaces after ##
  4. Spelling: targettingtargeting (line 108)

<input type="range" class="calc-input" data-calc="user-count-input" min="10" max="15000" value="1000" step="10">

<div class="calc-divider">
<a href="#" data-calc="show-advanced" class="calc-link" onclick="event.preventDefault();">Advanced →</a>
Copy link
Member

Choose a reason for hiding this comment

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

Accessibility & Separation of Concerns

These <a href="#"> elements with inline onclick="event.preventDefault();" have two issues:

  1. Separation of concerns: The preventDefault is split between the HTML template and the JS (attachListeners already wires click handlers via data-calc selectors). A future developer reading the JS would not know preventDefault is already handled in the HTML.

  2. Accessibility: Screen readers announce <a href="#"> as links to the page top. Since these trigger in-page behavior with no navigation target, they should be <button type="button"> elements styled to match the current link appearance.

Suggestion:

<button type="button" data-calc="show-advanced" class="calc-link">Advanced →</button>

Then add e.preventDefault() in the JS click handler in attachListeners (or remove it entirely since buttons do not navigate).

);

els.popPerUser.textContent = m.popPerUser.toFixed();
const popPct = updateRangeMarker(els.popPerUserMarker, m.popPerUser, 1, 250);
Copy link
Member

Choose a reason for hiding this comment

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

Magic numbers not in DEFAULTS

The bounds 250 (max people per user) and 20000 (line 121, max docs per user) are hardcoded here but have no corresponding entry in the DEFAULTS object at the top of the file. If these values need tuning (the same way USERS_PER_CPU and MEDIC_DOCS_PER_GB were derived from production data), they are invisible to future maintainers.

Suggestion: add them to DEFAULTS:

MAX_POP_PER_USER: 250,
MAX_DOCS_PER_USER: 20000,

{{ $js := resources.Get "js/cost-calculator.js" }}
<script src="{{ $js.RelPermalink }}"></script>

<div class="cht-cost-calculator calc-grid" id="calc-container-{{ $id }}">
Copy link
Member

Choose a reason for hiding this comment

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

Unused CSS class calc-grid

The class calc-grid is applied here but is never defined in cost-calculator.css. The grid layout is defined on .cht-cost-calculator instead. This looks like a leftover from an earlier iteration — either remove it or add the corresponding CSS rule.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

Status: 💻 In Progress

Development

Successfully merging this pull request may close these issues.

4 participants