mirror of
https://github.com/maybe-finance/maybe.git
synced 2025-07-19 13:19:39 +02:00
Budgeting V1 (#1609)
* Budgeting V1 * Basic UI template * Fully scaffolded budgeting v1 * Basic working budget * Finalize donut chart for budgets * Allow categorization of loan payments for budget * Include loan payments in incomes_and_expenses scope * Add budget allocations progress * Empty states * Clean up budget methods * Category aggregation queries * Handle overage scenarios in form * Finalize budget donut chart controller * Passing tests * Fix allocation naming * Add income category migration * Native support for uncategorized budget category * Formatting * Fix subcategory sort order, padding * Fix calculation for category rollups in budget
This commit is contained in:
parent
413ec6cbed
commit
195ec85d96
61 changed files with 2044 additions and 140 deletions
168
app/javascript/controllers/donut_chart_controller.js
Normal file
168
app/javascript/controllers/donut_chart_controller.js
Normal file
|
@ -0,0 +1,168 @@
|
|||
import { Controller } from "@hotwired/stimulus";
|
||||
import * as d3 from "d3";
|
||||
|
||||
// Connects to data-controller="donut-chart"
|
||||
export default class extends Controller {
|
||||
static targets = ["chartContainer", "contentContainer", "defaultContent"];
|
||||
static values = {
|
||||
segments: { type: Array, default: [] },
|
||||
unusedSegmentId: { type: String, default: "unused" },
|
||||
overageSegmentId: { type: String, default: "overage" },
|
||||
segmentHeight: { type: Number, default: 3 },
|
||||
segmentOpacity: { type: Number, default: 1 },
|
||||
};
|
||||
|
||||
#viewBoxSize = 100;
|
||||
#minSegmentAngle = this.segmentHeightValue * 0.01;
|
||||
|
||||
connect() {
|
||||
this.#draw();
|
||||
document.addEventListener("turbo:load", this.#redraw);
|
||||
this.element.addEventListener("mouseleave", this.#clearSegmentHover);
|
||||
}
|
||||
|
||||
disconnect() {
|
||||
this.#teardown();
|
||||
document.removeEventListener("turbo:load", this.#redraw);
|
||||
this.element.removeEventListener("mouseleave", this.#clearSegmentHover);
|
||||
}
|
||||
|
||||
get #data() {
|
||||
const totalPieValue = this.segmentsValue.reduce(
|
||||
(acc, s) => acc + Number(s.amount),
|
||||
0,
|
||||
);
|
||||
|
||||
// Overage is always first segment, unused is always last segment
|
||||
return this.segmentsValue
|
||||
.filter((s) => s.amount > 0)
|
||||
.map((s) => ({
|
||||
...s,
|
||||
amount: Math.max(
|
||||
Number(s.amount),
|
||||
totalPieValue * this.#minSegmentAngle,
|
||||
),
|
||||
}))
|
||||
.sort((a, b) => {
|
||||
if (a.id === this.overageSegmentIdValue) return -1;
|
||||
if (b.id === this.overageSegmentIdValue) return 1;
|
||||
if (a.id === this.unusedSegmentIdValue) return 1;
|
||||
if (b.id === this.unusedSegmentIdValue) return -1;
|
||||
return b.amount - a.amount;
|
||||
});
|
||||
}
|
||||
|
||||
#redraw = () => {
|
||||
this.#teardown();
|
||||
this.#draw();
|
||||
};
|
||||
|
||||
#teardown() {
|
||||
d3.select(this.chartContainerTarget).selectAll("*").remove();
|
||||
}
|
||||
|
||||
#draw() {
|
||||
const svg = d3
|
||||
.select(this.chartContainerTarget)
|
||||
.append("svg")
|
||||
.attr("viewBox", `0 0 ${this.#viewBoxSize} ${this.#viewBoxSize}`) // Square aspect ratio
|
||||
.attr("preserveAspectRatio", "xMidYMid meet")
|
||||
.attr("class", "w-full h-full");
|
||||
|
||||
const pie = d3
|
||||
.pie()
|
||||
.sortValues(null) // Preserve order of segments
|
||||
.value((d) => d.amount);
|
||||
|
||||
const mainArc = d3
|
||||
.arc()
|
||||
.innerRadius(this.#viewBoxSize / 2 - this.segmentHeightValue)
|
||||
.outerRadius(this.#viewBoxSize / 2)
|
||||
.cornerRadius(this.segmentHeightValue)
|
||||
.padAngle(this.#minSegmentAngle);
|
||||
|
||||
const segmentArcs = svg
|
||||
.append("g")
|
||||
.attr(
|
||||
"transform",
|
||||
`translate(${this.#viewBoxSize / 2}, ${this.#viewBoxSize / 2})`,
|
||||
)
|
||||
.selectAll("arc")
|
||||
.data(pie(this.#data))
|
||||
.enter()
|
||||
.append("g")
|
||||
.attr("class", "arc pointer-events-auto")
|
||||
.append("path")
|
||||
.attr("data-segment-id", (d) => d.data.id)
|
||||
.attr("data-original-color", this.#transformRingColor)
|
||||
.attr("fill", this.#transformRingColor)
|
||||
.attr("d", mainArc);
|
||||
|
||||
// Ensures that user can click on default content without triggering hover on a segment if that is their intent
|
||||
let hoverTimeout = null;
|
||||
|
||||
segmentArcs
|
||||
.on("mouseover", (event) => {
|
||||
hoverTimeout = setTimeout(() => {
|
||||
this.#clearSegmentHover();
|
||||
this.#handleSegmentHover(event);
|
||||
}, 150);
|
||||
})
|
||||
.on("mouseleave", () => {
|
||||
clearTimeout(hoverTimeout);
|
||||
});
|
||||
}
|
||||
|
||||
#transformRingColor = ({ data: { id, color } }) => {
|
||||
if (id === this.unusedSegmentIdValue || id === this.overageSegmentIdValue) {
|
||||
return color;
|
||||
}
|
||||
|
||||
const reducedOpacityColor = d3.color(color);
|
||||
reducedOpacityColor.opacity = this.segmentOpacityValue;
|
||||
return reducedOpacityColor;
|
||||
};
|
||||
|
||||
// Highlights segment and shows segment specific content (all other segments are grayed out)
|
||||
#handleSegmentHover(event) {
|
||||
const segmentId = event.target.dataset.segmentId;
|
||||
const template = this.element.querySelector(`#segment_${segmentId}`);
|
||||
const unusedSegmentId = this.unusedSegmentIdValue;
|
||||
|
||||
if (!template) return;
|
||||
|
||||
d3.select(this.chartContainerTarget)
|
||||
.selectAll("path")
|
||||
.attr("fill", function () {
|
||||
if (this.dataset.segmentId === segmentId) {
|
||||
if (this.dataset.segmentId === unusedSegmentId) {
|
||||
return "#A3A3A3";
|
||||
}
|
||||
|
||||
return this.dataset.originalColor;
|
||||
}
|
||||
|
||||
return "#F0F0F0";
|
||||
});
|
||||
|
||||
this.defaultContentTarget.classList.add("hidden");
|
||||
template.classList.remove("hidden");
|
||||
}
|
||||
|
||||
// Restores original segment colors and hides segment specific content
|
||||
#clearSegmentHover = () => {
|
||||
this.defaultContentTarget.classList.remove("hidden");
|
||||
|
||||
d3.select(this.chartContainerTarget)
|
||||
.selectAll("path")
|
||||
.attr("fill", function () {
|
||||
return this.dataset.originalColor;
|
||||
});
|
||||
|
||||
for (const child of this.contentContainerTarget.children) {
|
||||
if (child !== this.defaultContentTarget) {
|
||||
child.classList.add("hidden");
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
Loading…
Add table
Add a link
Reference in a new issue