github.com/shoshinnikita/budget-manager@v0.7.1-0.20220131195411-8c46ff1c6778/templates/search_spends.html (about) 1 <!DOCTYPE html> 2 <html lang="en"> 3 4 <head> 5 <meta charset="UTF-8"> 6 <meta name="viewport" content="width=device-width, initial-scale=1.0"> 7 <title>Search & Statistics | Budget Manager</title> 8 9 <!-- Theme Switcher --> 10 <script src="{{ asStaticURL `/static/js/theme-switcher.js` }}"></script> 11 12 <link rel="stylesheet" href="{{ asStaticURL `/static/css/common.css` }}"> 13 14 <style> 15 input[type=date] { 16 min-width: 120px; 17 } 18 19 .tooltip { 20 position: relative; 21 } 22 23 .tooltip .tooltip__text { 24 border: 2px solid var(--border-color--accent); 25 border-radius: 5px; 26 font-size: 14px; 27 padding: 3px 10px; 28 position: absolute; 29 top: 0; 30 visibility: hidden; 31 white-space: nowrap; 32 } 33 34 .tooltip:hover .tooltip__text { 35 visibility: visible; 36 } 37 38 /* Inspired by https://stackoverflow.com/a/23429132/7752659 */ 39 40 .tooltip .tooltip__text::after, 41 .tooltip .tooltip__text::before { 42 border: 7px solid; 43 border-color: transparent var(--border-color--accent) transparent transparent; 44 content: ""; 45 position: absolute; 46 right: 100%; 47 top: 1px; 48 z-index: 1; 49 } 50 51 .tooltip .tooltip__text::before { 52 border-width: 5px; 53 border-color: transparent var(--border-color--accent) transparent transparent; 54 top: 3px; 55 z-index: 2; 56 } 57 58 /* | Header */ 59 60 #header { 61 position: relative; 62 } 63 64 #header__current-month-link { 65 position: absolute; 66 right: 0px; 67 top: 0; 68 } 69 70 #header__current-month-link svg { 71 height: 30px; 72 width: 30px; 73 } 74 75 /* | Search */ 76 77 #content { 78 column-gap: 20px; 79 display: grid; 80 grid-template-columns: 1fr 5fr; 81 height: 100%; 82 } 83 84 /* || Filters */ 85 86 #filters { 87 height: 100%; 88 } 89 90 #filters .card__body { 91 padding-left: 10px; 92 padding-right: 10px; 93 } 94 95 .filter { 96 margin-bottom: 15px; 97 } 98 99 .filter:last-child { 100 margin-bottom: 0; 101 } 102 103 #filters__cost, 104 #filters__time { 105 column-gap: 5px; 106 display: grid; 107 grid-template-columns: 1fr auto 1fr; 108 } 109 110 .filters__separator::before { 111 content: "–"; 112 } 113 114 #filters__types { 115 margin-left: auto; 116 margin-right: auto; 117 min-width: 60%; 118 width: min-content; 119 } 120 121 #filters__types__header { 122 border-bottom: 1px solid var(--border-color); 123 margin: 0 auto 7px; 124 padding: 0 10px; 125 text-align: center; 126 width: min-content; 127 white-space: nowrap; 128 } 129 130 .filters__types__type { 131 white-space: nowrap; 132 } 133 134 #filters__buttons { 135 column-gap: 20px; 136 display: grid; 137 grid-template-columns: repeat(2, min-content); 138 justify-content: center; 139 } 140 141 .filters__button.feather-icon>svg { 142 height: 25px; 143 width: 25px; 144 } 145 146 /* || Result */ 147 148 #result { 149 display: grid; 150 grid-template-rows: min-content auto; 151 position: relative; 152 row-gap: 10px; 153 overflow-y: auto; 154 } 155 156 #result__no-spends { 157 font-size: 25px; 158 left: 50%; 159 position: absolute; 160 text-align: center; 161 top: 30%; 162 transform: translateX(-50%); 163 } 164 165 #result__tab-titles { 166 border-bottom: 1px solid var(--border-color); 167 column-gap: 20px; 168 display: grid; 169 grid-template-columns: repeat(2, min-content); 170 justify-content: center; 171 } 172 173 .result__tab-title { 174 cursor: pointer; 175 font-size: 19px; 176 margin-bottom: 2px; 177 padding: 2px 5px; 178 } 179 180 .result__tab-title:focus, 181 .result__tab-title:hover, 182 .result__tab-title--chosen { 183 border-bottom: 2px solid var(--border-color--accent); 184 margin-bottom: 0; 185 } 186 187 #result__tabs { 188 background-color: var(--base-color); 189 overflow-y: auto; 190 z-index: 0; 191 } 192 193 .result__tab { 194 display: none; 195 height: 100%; 196 } 197 198 .result__tab--chosen { 199 display: block; 200 } 201 202 /* ||| Spends */ 203 204 #spends__table { 205 /* 206 We have to overwrite property 'border-collapse' because it doesn't work properly with 'position: sticky': 207 https://stackoverflow.com/a/53559396/7752659 208 */ 209 border-collapse: separate; 210 border-spacing: 0; 211 } 212 213 #spends__table th { 214 background-color: var(--background-color); 215 border-bottom: 1px solid var(--border-color--accent); 216 font-size: 18px; 217 position: sticky; 218 top: 0; 219 } 220 221 #spends__table th, 222 #spends__table td { 223 padding-right: 15px; 224 padding-left: 15px; 225 } 226 227 #spends__table th:first-child, 228 #spends__table td:first-child { 229 padding-left: 5px; 230 } 231 232 #spends__table th:last-child, 233 #spends__table td:last-child { 234 padding-right: 5px; 235 } 236 237 #spends__table th * { 238 color: inherit 239 } 240 241 .spends__table__date, 242 .spends__table__type, 243 .spends__table__cost, 244 .spends__table__link { 245 width: 1px; 246 white-space: nowrap; 247 } 248 249 #spends__table .spends__table__cost { 250 padding-right: 20px; 251 } 252 253 .spends__table__cost { 254 text-align: right; 255 } 256 257 .spends__table__link { 258 text-align: center; 259 z-index: 1; 260 } 261 262 .spends__table__link .feather-icon>svg { 263 height: 20px; 264 width: 20px; 265 } 266 267 .spends__table__sort-icon__wrapper.feather-icon { 268 display: inline-grid; 269 grid-template-rows: 1fr 1fr; 270 vertical-align: middle; 271 } 272 273 .spends__table__sort-icon__wrapper svg { 274 height: 12px; 275 stroke-width: 4; 276 width: 12px; 277 } 278 279 .spends__table__sort-icon__wrapper svg.chosen { 280 opacity: 1; 281 stroke: var(--icon-color--hover); 282 } 283 284 /* ||| Statistics */ 285 286 #statistics__charts { 287 column-gap: 50px; 288 display: grid; 289 grid-template-columns: 1fr 2fr; 290 grid-template-rows: 1fr 1fr; 291 grid-template-areas: 292 "spent-by-spend-type cost-distribution" 293 "spent-by-day spent-by-day"; 294 row-gap: 30px; 295 overflow-y: auto; 296 width: 100%; 297 } 298 299 #spent-by-spend-type { 300 grid-area: spent-by-spend-type; 301 } 302 303 #spent-by-spend-type__total-cost { 304 font-size: 18px; 305 left: 50%; 306 position: absolute; 307 text-align: center; 308 top: 50%; 309 transform: translate(-50%, -50%); 310 z-index: -1; 311 } 312 313 #cost-distribution { 314 grid-area: cost-distribution; 315 } 316 317 #spent-by-day { 318 grid-area: spent-by-day; 319 } 320 321 .statistics__chart-title { 322 font-size: 18px; 323 margin-bottom: 15px; 324 text-align: center; 325 } 326 327 .statistics__chart { 328 height: 80%; 329 position: relative; 330 } 331 332 /* | Layouts */ 333 334 /* For medium screens (<= 1350px) */ 335 @media (max-width: 1350px) { 336 337 #filters__cost, 338 #filters__time { 339 grid-template-columns: 1fr; 340 grid-template-rows: 1fr 1fr; 341 row-gap: 10px; 342 } 343 344 .filters__separator { 345 display: none; 346 } 347 348 /* Hide notes */ 349 .spends__table__notes { 350 display: none; 351 } 352 353 #statistics__charts { 354 grid-template-columns: 1fr; 355 grid-template-rows: 1fr 1fr 1fr; 356 grid-template-areas: 357 "spent-by-spend-type" 358 "cost-distribution" 359 "spent-by-day"; 360 height: unset; 361 } 362 363 #spent-by-spend-type .statistics__chart { 364 max-width: 500px; 365 } 366 367 .statistics__chart { 368 margin: auto; 369 width: 90%; 370 } 371 } 372 </style> 373 </head> 374 375 <body> 376 <div id="app"> 377 <div id="header"> 378 <div> 379 <span class="header__path__element">Search</span> 380 <span class="header__path__element">Spends</span> 381 </div> 382 383 <div id="header__current-month-link"> 384 <a href="/" class="feather-icon" title="Go to the Current Month"> 385 {{ template "components/icon" "home" }} 386 </a> 387 </div> 388 </div> 389 390 <div id="content"> 391 <!-- Filters --> 392 <div id="filters" class="card"> 393 <div class="card__title noselect">Filters</div> 394 395 <form action="/search/spends" class="card__body"> 396 <!-- Title --> 397 <div id="filters__title" class="filter"> 398 <input type="text" name="title" placeholder="Title" title="Title"> 399 </div> 400 401 <!-- Notes --> 402 <div id="filters__notes" class="filter"> 403 <input type="text" name="notes" placeholder="Notes" title="Notes"> 404 </div> 405 406 <!-- Cost --> 407 <div id="filters__cost" class="filter"> 408 <div id="filters__cost__min"> 409 <input type="text" name="min_cost" placeholder="Min Cost" title="Minimal Cost"> 410 </div> 411 412 <div class="filters__separator"></div> 413 414 <div id="filters__cost__max"> 415 <input type="text" name="max_cost" placeholder="Max Cost" title="Maximal Cost"> 416 </div> 417 </div> 418 419 <!-- Time --> 420 <div id="filters__time" class="filter"> 421 <div id="filters__time__after"> 422 <input type="date" name="after" title="After"> 423 </div> 424 425 <div class="filters__separator"></div> 426 427 <div id="filters__time__before"> 428 <input type="date" name="before" title="Before"> 429 </div> 430 </div> 431 432 <!-- Spend Types --> 433 <div id="filters__types" class="filter"> 434 <div id="filters__types__header" class="noselect">Spend Types</div> 435 <!-- Without Type option --> 436 <div class="filters__types__type"> 437 <input id="filters__types__type-0" type="checkbox" name="type_id" value="0"> 438 <label for="filters__types__type-0" title="Use this option to search for Spends without type">Without Type</label> 439 </div> 440 {{ range .SpendTypes }} 441 <div class="filters__types__type"> 442 <input id="filters__types__type-{{ .ID }}" type="checkbox" name="type_id" value="{{ .ID }}"> 443 <label for="filters__types__type-{{ .ID }}">{{ .FullName }}</label> 444 </div> 445 {{ end }} 446 </div> 447 448 <!-- Hidden fields --> 449 <div style="display: none;"> 450 <input id="filters__types__hidden__sort" type="text" name="sort" value=""> 451 <input id="filters__types__hidden__order" type="text" name="order" value=""> 452 </div> 453 454 <!-- Buttons --> 455 <div id="filters__buttons"> 456 <button type="button" class="filters__button feather-icon" title="Reset" onclick="resetForm(this.form)"> 457 {{ template "components/icon" "rotate-ccw" }} 458 </button> 459 460 <button type="submit" class="filters__button feather-icon" title="Search" onclick="updateFormActionHash(this.form)"> 461 {{ template "components/icon" "search" }} 462 </button> 463 </div> 464 </form> 465 </div> 466 467 <!-- Result --> 468 <div id="result"> 469 {{ if not .Spends }} 470 <div id="result__no-spends" class="noselect">No Spends found</div> 471 {{ else }} 472 <div id="result__tab-titles" class="noselect"> 473 <button class="result__tab-title" data-title-for-tab="spends" onclick="switchTab('spends')"> 474 Spends 475 </button> 476 <button class="result__tab-title" data-title-for-tab="statistics" onclick="switchTab('statistics')"> 477 Statistics 478 </button> 479 </div> 480 481 <div id="result__tabs"> 482 <!-- Spends --> 483 <div id="spends" class="result__tab"> 484 <table id="spends__table"> 485 <thead> 486 <tr class="noselect"> 487 <th class="spends__table__date"> 488 <span>Date</span> 489 <button class="feather-icon spends__table__sort-icon__wrapper" title="Sort" onclick="searchWithSort('date')"> 490 {{ template "components/icon" "chevron-up" }} 491 {{ template "components/icon" "chevron-down" }} 492 </button> 493 </th> 494 <th class="spends__table__title"> 495 <span>Title</span> 496 <button class="feather-icon spends__table__sort-icon__wrapper" title="Sort" onclick="searchWithSort('title')"> 497 {{ template "components/icon" "chevron-up" }} 498 {{ template "components/icon" "chevron-down" }} 499 </button> 500 </th> 501 <th class="spends__table__notes">Notes</th> 502 <th class="spends__table__type">Type</th> 503 <th class="spends__table__cost"> 504 <span>Cost</span> 505 <button class="feather-icon spends__table__sort-icon__wrapper" title="Sort" onclick="searchWithSort('cost')"> 506 {{ template "components/icon" "chevron-up" }} 507 {{ template "components/icon" "chevron-down" }} 508 </button> 509 </th> 510 <th class="spends__table__link">Link</th> 511 </tr> 512 </thead> 513 514 <tbody> 515 {{ range .Spends }} 516 <tr> 517 <td class="spends__table__date">{{ .Year }}-{{ printf "%02d" .Month }}-{{ printf "%02d" .Day }}</td> 518 <td class="spends__table__title">{{ .Title }}</td> 519 <td class="spends__table__notes">{{ .Notes }}</td> 520 <td class="spends__table__type"> 521 {{ if .Type }} 522 <span>{{ .Type.Name }}</span> 523 {{ else }} 524 <span>-</span> 525 {{ end }} 526 </td> 527 <td class="spends__table__cost">{{ .Cost }}</td> 528 <td class="spends__table__link"> 529 <a href="/months/month?year={{ .Year }}&month={{ printf `%d` .Month }}#{{ .Day }}" class="feather-icon" title="View Day"> 530 {{ template "components/icon" "external-link" }} 531 </a> 532 </td> 533 </tr> 534 {{ end }} 535 </tbody> 536 </table> 537 </div> 538 539 <!-- Statistics --> 540 <div id="statistics" class="result__tab"> 541 <div id="statistics__charts"> 542 <!-- Total cost is not centered vertically without 'height: min-content' for some reason --> 543 <div id="spent-by-spend-type" style="height: min-content;"> 544 <div class="statistics__chart-title noselect">Spent by Type</div> 545 <div class="statistics__chart"> 546 <div id="spent-by-spend-type__total-cost"> 547 <span class="money--lose">{{ .TotalCost }}</span> 548 <br> 549 <span>({{ len .Spends }})</span> 550 </div> 551 552 <canvas id="spent-by-spend-type__chart"></canvas> 553 </div> 554 </div> 555 556 <div id="cost-distribution"> 557 <div class="statistics__chart-title"> 558 <span class="noselect">Cost Distribution</span> 559 <span class="feather-icon tooltip" style="vertical-align: middle;"> 560 {{ template "components/icon" "help-circle" }} 561 562 <span class="tooltip__text">Without 5% of the smallest and largest Spends</span> 563 </span> 564 </div> 565 <div class="statistics__chart"> 566 <canvas id="cost-distribution__chart"></canvas> 567 </div> 568 </div> 569 570 <div id="spent-by-day"> 571 <div class="statistics__chart-title noselect">Spent by Day</div> 572 <div class="statistics__chart"> 573 <canvas id="spent-by-day__chart"></canvas> 574 </div> 575 </div> 576 </div> 577 </div> 578 </div> 579 {{ end }} 580 </div> 581 </div> 582 583 {{ template "components/footer.html" .Footer }} 584 </div> 585 586 <script> 587 // Sort and order must be set in the first 'load' event listener 588 let CurrentSort = ""; 589 let CurrentOrder = ""; 590 591 // Update Search Options with query params 592 window.addEventListener("load", () => { 593 const query = new URLSearchParams(location.search); 594 595 // Title 596 const title = query.get("title"); 597 setOptionValue("filters__title", title); 598 599 // Notes 600 const notes = query.get("notes"); 601 setOptionValue("filters__notes", notes); 602 603 // Min Cost 604 const minCost = query.get("min_cost"); 605 setOptionValue("filters__cost__min", minCost); 606 607 // Max cost 608 const maxCost = query.get("max_cost"); 609 setOptionValue("filters__cost__max", maxCost); 610 611 // After 612 const after = query.get("after"); 613 setOptionValue("filters__time__after", after); 614 615 // Before 616 const before = query.get("before"); 617 setOptionValue("filters__time__before", before); 618 619 // Sort and Order 620 621 CurrentSort = query.get("sort"); 622 let sortSelector = "" 623 if (CurrentSort === "title") { 624 sortSelector = ".spends__table__title" 625 } else if (CurrentSort === "cost") { 626 sortSelector = ".spends__table__cost" 627 } else { 628 // Default sort is by date 629 CurrentSort = "date"; 630 sortSelector = ".spends__table__date" 631 } 632 633 CurrentOrder = query.get("order"); 634 let orderSelector = ""; 635 if (CurrentOrder === "desc") { 636 orderSelector = "svg:last-child"; 637 } else { 638 // Default order is asc 639 CurrentOrder = "asc"; 640 orderSelector = "svg:first-child"; 641 } 642 643 const selector = sortSelector + " " + orderSelector; 644 const icon = document.querySelector(selector); 645 if (icon !== null) { 646 icon.classList.add("chosen"); 647 } 648 649 // Don't set sort and order in form. It allows to 'reset' sort/order by clicking 'Search' button 650 651 // Spend Types 652 const typeIDs = query.getAll("type_id"); 653 for (let i = 0; i < typeIDs.length; i++) { 654 const elemID = "filters__types__type-" + typeIDs[i]; 655 const checkbox = document.getElementById(elemID); 656 if (!checkbox) { 657 console.error(`element '${elemID}' doesn't exist`); 658 continue; 659 } 660 checkbox.checked = true; 661 } 662 }) 663 664 /** 665 * @param {string} parentID - id of an input parent 666 * @param {string} value - new value 667 */ 668 function setOptionValue(parentID, value) { 669 if (value === "") { 670 return; 671 } 672 673 const parent = document.getElementById(parentID); 674 if (parent === undefined) { 675 console.error(`element '${parentID}' doesn't exist`); 676 return; 677 } 678 if (parent.childElementCount < 1) { 679 console.error(`element '${parentID}' doesn't have any children`); 680 return; 681 } 682 if (parent.children[0].tagName !== "INPUT") { 683 console.error(`the first child of element '${parentID}' must be <input>`); 684 return; 685 } 686 687 const elem = parent.children[0]; 688 elem.value = value; 689 } 690 691 // Set date titles 692 window.addEventListener("load", () => { 693 const dates = document.querySelectorAll("td.spends__table__date"); 694 for (let i = 0; i < dates.length; i++) { 695 const date = dates[i].textContent; 696 dates[i].title = convertDateToHumanReadableFormat(date); 697 } 698 }) 699 700 let humanReadableDateCache = {}; 701 /** 702 * Format date '2019-11-01' as 'Friday, November 1, 2019' 703 */ 704 function convertDateToHumanReadableFormat(dateStr) { 705 const dateFormatOptions = { weekday: "long", year: "numeric", month: "long", day: "numeric" }; 706 707 let date = humanReadableDateCache[dateStr]; 708 if (!date) { 709 date = new Date(dateStr).toLocaleDateString("en-US", dateFormatOptions); 710 humanReadableDateCache[dateStr] = date; 711 } 712 return date; 713 } 714 715 // Group Spends by date with accent color 716 window.addEventListener("load", () => { 717 if (CurrentSort !== "date") { 718 // Group Spends only when they are sorted by date 719 return; 720 } 721 722 let lastDate = ""; 723 const rows = document.querySelectorAll("#spends__table tbody tr"); 724 for (const row of rows) { 725 const dateColumn = row.querySelector("td.spends__table__date"); 726 if (!dateColumn) continue; 727 728 const date = dateColumn.innerText; 729 if (lastDate === "") { 730 lastDate = date; 731 continue; 732 } 733 if (lastDate === date) { 734 continue; 735 } 736 737 lastDate = date; 738 739 const columns = row.querySelectorAll("td"); 740 for (const column of columns) { 741 column.style.borderTop = "1px solid var(--border-color--accent)"; 742 } 743 } 744 }) 745 746 /** 747 * Change Sort and Order and submit form 748 */ 749 function searchWithSort(newSort) { 750 let sortValue = ""; 751 let orderValue = ""; 752 753 if (CurrentSort !== newSort) { 754 // Change sort type and set order to 'asc' 755 sortValue = newSort; 756 orderValue = "asc"; 757 } else { 758 // Change sort order 759 if (CurrentOrder === "asc") { 760 orderValue = "desc"; 761 } else if (CurrentOrder === "desc") { 762 orderValue = "asc"; 763 } else { 764 console.error(`invalid current order value: '${CurrentOrder}'`); 765 orderValue = "asc"; 766 } 767 768 sortValue = CurrentSort; 769 } 770 771 const sortInput = document.getElementById("filters__types__hidden__sort"); 772 sortInput.value = sortValue; 773 const orderInput = document.getElementById("filters__types__hidden__order"); 774 orderInput.value = orderValue; 775 776 // Submit form 777 const form = document.querySelector("#filters form"); 778 if (form === null) { 779 console.error("couldn't get form") 780 return; 781 } 782 form.submit(); 783 } 784 785 // Switch tab according to url hash 786 window.addEventListener("load", () => { 787 // Get hash without '#' 788 let tabID = window.location.hash.slice(1); 789 if (tabID != "spends" && tabID != "statistics") { 790 tabID = "spends"; 791 } 792 switchTab(tabID); 793 }) 794 795 /** 796 * @param {HTMLFormElement} form 797 */ 798 function updateFormActionHash(form) { 799 const action = form.action; 800 if (action.includes(window.location.hash)) { 801 return; 802 } 803 form.action = action + window.location.hash; 804 } 805 806 /** 807 * Reset form exclude date inputs 808 * 809 * @param {HTMLFormElement} form 810 */ 811 function resetForm(form) { 812 const dateInputs = form.querySelectorAll("input[type='date']"); 813 814 // Store date input values 815 const values = {}; 816 for (const input of dateInputs) { 817 values[input.name] = input.value; 818 } 819 820 // Reset form 821 form.reset(); 822 823 // Restore date inputs 824 for (const input of dateInputs) { 825 input.value = values[input.name]; 826 } 827 } 828 829 /** 830 * @param {string} tabID - tab id to display 831 */ 832 function switchTab(tabID) { 833 // Switch tab title 834 const chosenTabTitleClass = "result__tab-title--chosen"; 835 const attrWithTabID = "data-title-for-tab"; 836 837 const tabTitles = document.querySelectorAll("#result__tab-titles .result__tab-title"); 838 for (let i = 0; i < tabTitles.length; i++) { 839 tabTitles[i].classList.remove(chosenTabTitleClass); 840 841 const attr = tabTitles[i].getAttribute(attrWithTabID); 842 if (attr === tabID) { 843 tabTitles[i].classList.add(chosenTabTitleClass); 844 } 845 } 846 847 // Switch tab 848 const chosenTabClass = "result__tab--chosen"; 849 850 const tabs = document.querySelectorAll("#result__tabs .result__tab"); 851 for (let i = 0; i < tabs.length; i++) { 852 tabs[i].classList.remove(chosenTabClass); 853 854 if (tabs[i].id == tabID) { 855 tabs[i].classList.add(chosenTabClass); 856 } 857 } 858 859 // Update url hash 860 window.location.hash = tabID; 861 } 862 </script> 863 864 <!-- Statistics Charts --> 865 {{ if .Spends }} 866 <script src="{{ asStaticURL `/static/vendor/chart.js/chart.min.js` }}"></script> 867 <script src="{{ asStaticURL `/static/vendor/chartjs-plugin-datalabels/chartjs-plugin-datalabels.min.js` }}"></script> 868 <script src="{{ asStaticURL `/static/js/chart.js` }}"></script> 869 <script> 870 Chart.register(ChartDataLabels); 871 Chart.defaults.plugins.tooltip.displayColors = false; 872 873 const invisibleColor = "#00000000"; 874 875 function getBackgroundColor() { 876 return isDarkTheme() ? "#26292f" : "#dddddd"; 877 } 878 879 function getHoverBackgroundColor() { 880 return isDarkTheme() ? "#31343a" : "#d4d4d4"; 881 } 882 883 function getBorderColor() { 884 return isDarkTheme() ? "#0d1117" : "#ffffff"; 885 } 886 887 // Spent by Spend Type chart 888 889 const spentBySpendTypeStatistics = JSON.parse(`{{ .SpentBySpendTypeDatasets }}`); 890 let spendBySpendTypeDatasets = []; 891 for (const data of spentBySpendTypeStatistics) { 892 spendBySpendTypeDatasets.push({ 893 data: data.map(v => v["spent"]), 894 labels: data.map(v => v["spend_type_name"]), 895 backgroundColor: data.map(v => v["spend_type_name"] ? getBackgroundColor() : invisibleColor), 896 hoverBackgroundColor: data.map(v => v["spend_type_name"] ? getHoverBackgroundColor() : invisibleColor), 897 borderColor: data.map(v => v["spend_type_name"] ? getBorderColor() : invisibleColor), 898 hoverBorderColor: data.map(v => invisibleColor), // Hide hover border colors 899 }); 900 } 901 902 const totalSpent = Math.abs(`{{ .TotalCost }}`); 903 // labelSeparator is En dash - https://en.wikipedia.org/wiki/Dash#En_dash 904 const labelSeparator = " – "; 905 906 const spentBySpendTypeCtx = document.getElementById("spent-by-spend-type__chart").getContext("2d"); 907 const spentBySpendTypeChart = new Chart(spentBySpendTypeCtx, { 908 type: "doughnut", 909 data: { 910 datasets: spendBySpendTypeDatasets, 911 }, 912 options: { 913 maintainAspectRatio: true, 914 // Tune doughnut 915 cutout: "30%", 916 rotation: 225, 917 // Plugins 918 plugins: { 919 tooltip: { 920 filter: function (tooltipItem, index, tooltipItems, data) { 921 const labels = data.datasets[tooltipItem.datasetIndex].labels; 922 return labels ? labels[tooltipItem.dataIndex] !== "" : true; 923 }, 924 callbacks: { 925 label: function (tooltipItem) { 926 const dataset = tooltipItem.dataset; 927 const index = tooltipItem.dataIndex; 928 return dataset.labels[index] + labelSeparator + dataset.data[index]; 929 }, 930 } 931 }, 932 datalabels: { 933 textAlign: "center", 934 formatter: function (value, context) { 935 const dataset = context.chart.data.datasets[context.datasetIndex]; 936 if (!dataset || !dataset.labels) return ""; 937 938 const label = dataset.labels[context.dataIndex]; 939 if (!label) return ""; 940 941 return `${label}\n${(value / totalSpent * 100).toFixed(2)}%`; 942 }, 943 display: function (context) { 944 const dataset = context.chart.data.datasets[context.datasetIndex]; 945 if (!dataset || !dataset.labels) return false; 946 947 const label = dataset.labels[context.dataIndex]; 948 if (!label) return false; 949 950 return "auto"; 951 } 952 } 953 } 954 } 955 }); 956 957 // Cost Distribution chart 958 959 const costIntervals = JSON.parse(`{{ .CostIntervals }}`); 960 let costIntervalLabels = []; 961 let costIntervalData = []; 962 for (const data of costIntervals) { 963 costIntervalLabels.push(data.from); 964 costIntervalData.push(data.total); 965 } 966 costIntervalLabels.push(costIntervals[costIntervals.length - 1].to); 967 968 const costIntervalsCtx = document.getElementById("cost-distribution__chart").getContext("2d"); 969 const costIntervalsChart = new Chart(costIntervalsCtx, { 970 type: "bar", 971 data: { 972 labels: new Array(costIntervalLabels.length - 1).fill(""), 973 datasets: [{ 974 data: costIntervalData, 975 backgroundColor: getBackgroundColor(), 976 hoverBackgroundColor: getHoverBackgroundColor(), 977 }], 978 }, 979 plugins: [ 980 { 981 // Update right padding to be able to display last interval 982 afterInit: chart => { 983 const longestLabel = costIntervalLabels[costIntervalLabels.length - 1]; 984 const textWidth = chart.ctx.measureText(longestLabel).width; 985 986 // Add additional 5px 987 chart.options.layout.padding.right = textWidth / 2 + 5; 988 chart.update(); 989 }, 990 // Draw intervals 991 afterDraw: chart => { 992 const axis = chart.scales["x"]; 993 const tickDistance = axis.width / (costIntervalLabels.length - 1); 994 995 const y = chart.height - 10; 996 const offset = axis.left; 997 998 const ctxFont = chart.ctx.font; 999 const fillStyle = chart.ctx.fillStyle; 1000 1001 // Sometimes canvas font can be bold (for example, on hover). Change it to normal while drawing intervals 1002 chart.ctx.font = chart.ctx.font.replace("bold", ""); 1003 chart.ctx.fillStyle = chart.options.color; 1004 1005 for (let i = 0; i < costIntervalLabels.length; i++) { 1006 const text = costIntervalLabels[i]; 1007 const textWidth = chart.ctx.measureText(text).width; 1008 const x = offset + tickDistance * i - textWidth / 2; 1009 1010 chart.ctx.save(); 1011 chart.ctx.fillText(text, x, y); 1012 chart.ctx.restore(); 1013 } 1014 1015 // Revert canvas options 1016 chart.ctx.font = ctxFont; 1017 chart.ctx.fillStyle = fillStyle; 1018 } 1019 } 1020 ], 1021 options: { 1022 plugins: { 1023 tooltip: { 1024 callbacks: { 1025 label: function (tooltipItem) { 1026 const interval = costIntervals[tooltipItem.dataIndex]; 1027 return `${tooltipItem.raw} – ${interval.count} Spends`; 1028 }, 1029 } 1030 }, 1031 datalabels: { 1032 display: function (context) { 1033 const dataset = context.chart.data.datasets[context.datasetIndex]; 1034 if (!dataset) return false; 1035 1036 const value = dataset.data[context.dataIndex]; 1037 if (!value) return false; 1038 1039 return "auto"; 1040 } 1041 } 1042 }, 1043 scales: { 1044 y: { ticks: { callback: Chart.Ticks.formatters.money } } 1045 } 1046 } 1047 }); 1048 1049 // Spent by Day chart 1050 1051 const spentByDayStatistics = JSON.parse(`{{ .SpentByDayDataset }}`); 1052 let spentByDayLabels = []; 1053 let spentByDayData = []; 1054 for (const data of spentByDayStatistics) { 1055 const month = data.month < 10 ? "0" + data.month : data.month; 1056 const day = data.day < 10 ? "0" + data.day : data.day; 1057 spentByDayLabels.push(`${data.year}-${month}-${day}`) 1058 1059 spentByDayData.push(data.spent); 1060 } 1061 1062 const spentByDayCtx = document.getElementById("spent-by-day__chart").getContext("2d"); 1063 const spentByDayChart = new Chart(spentByDayCtx, { 1064 type: "bar", 1065 data: { 1066 labels: spentByDayLabels, 1067 datasets: [{ 1068 data: spentByDayData, 1069 backgroundColor: getBackgroundColor(), 1070 hoverBackgroundColor: getHoverBackgroundColor(), 1071 }], 1072 }, 1073 options: { 1074 plugins: { 1075 tooltip: { 1076 callbacks: { 1077 title: function (tooltipItem) { return "" }, 1078 label: function (tooltipItem) { 1079 const date = convertDateToHumanReadableFormat(tooltipItem.label) 1080 return date + labelSeparator + tooltipItem.raw; 1081 }, 1082 } 1083 }, 1084 datalabels: { 1085 display: function (context) { 1086 const dataset = context.chart.data.datasets[context.datasetIndex]; 1087 if (!dataset) return false; 1088 1089 const value = dataset.data[context.dataIndex]; 1090 if (!value) return false; 1091 1092 return "auto"; 1093 } 1094 } 1095 }, 1096 scales: { 1097 y: { ticks: { callback: Chart.Ticks.formatters.money } } 1098 } 1099 } 1100 }); 1101 1102 window.addEventListener(themeChangeEventName, () => { 1103 const backgroundColor = getBackgroundColor(); 1104 const hoverBackgroundColor = getHoverBackgroundColor(); 1105 const borderColor = getBorderColor(); 1106 const gridLinesColor = getGridLinesColor(); 1107 1108 function getDatasetColor(color, newColor) { 1109 if (color instanceof Array) { 1110 for (let i = 0; i < color.length; i++) { 1111 if (color[i] === invisibleColor) { 1112 continue; 1113 } 1114 color[i] = newColor; 1115 } 1116 return color; 1117 } 1118 1119 if (typeof color === "string" || color instanceof String) { 1120 if (color === invisibleColor) { 1121 return color; 1122 } 1123 return newColor; 1124 } 1125 1126 // Indicate something went wrong 1127 return "red"; 1128 } 1129 1130 for (const chart of [spentBySpendTypeChart, costIntervalsChart, spentByDayChart]) { 1131 // Update dataset color 1132 for (const dataset of chart.data.datasets) { 1133 dataset.backgroundColor = getDatasetColor(dataset.backgroundColor, backgroundColor); 1134 dataset.hoverBackgroundColor = getDatasetColor(dataset.hoverBackgroundColor, hoverBackgroundColor); 1135 dataset.borderColor = getDatasetColor(dataset.borderColor, borderColor); 1136 } 1137 1138 // Update color of grid lines 1139 if (chart.options.scales) { 1140 for (const axis of [chart.options.scales.x, chart.options.scales.y]) { 1141 if (axis) { 1142 axis.grid.color = gridLinesColor 1143 } 1144 } 1145 } 1146 1147 chart.update(); 1148 } 1149 }) 1150 </script> 1151 {{ end }} 1152 1153 <!-- Link Formatter --> 1154 {{ if .Spends }} 1155 <script src="{{ asStaticURL `/static/js/link-formatter.js` }}"></script> 1156 <script> 1157 window.addEventListener("load", () => { 1158 formatLinks(".spends__table__notes"); 1159 }) 1160 </script> 1161 {{ end }} 1162 </body> 1163 1164 </html>