Загрузка данных
<!doctype html>
<html lang="ru">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="theme-color" content="#8067db" />
<meta
name="description"
content="Flow — открытый одностраничный сайт для учёта доходов и расходов. Все данные сохраняются в вашем браузере."
/>
<title>Flow — ваши финансы в порядке</title>
<!--
ЧИТАЕМЫЙ ИСХОДНЫЙ КОД САЙТА В ОДНОМ ФАЙЛЕ
Запуск: откройте этот файл в современном браузере.
Редактирование: откройте этот же файл в VS Code, Блокноте или другом редакторе.
Сборка, npm, React, CDN, дополнительные файлы и подключение к сети не нужны.
Ниже последовательно расположены:
1. CSS — оформление и адаптивная версия.
2. HTML — базовая разметка.
3. JavaScript — данные, шаблоны разделов, диаграммы, формы и обработчики.
Шрифты берутся из системы. Значки и диаграммы нарисованы обычным SVG.
Данные хранятся в localStorage. Для переноса используйте JSON-копию в настройках.
-->
<style>
@layer base, components, responsive;
@layer base {
:root {
font-family:
"Manrope",
-apple-system,
BlinkMacSystemFont,
"Segoe UI",
sans-serif;
color: #292936;
background: #f7f8fa;
font-synthesis: none;
--primary: #8067db;
--primary-dark: #6d54c6;
--ink: #292936;
--muted: #94949f;
--line: #ececf1;
--green: #559b80;
--peach: #d59476;
--sidebar: 226px;
}
* {
box-sizing: border-box;
}
body {
margin: 0;
min-width: 360px;
-webkit-font-smoothing: antialiased;
}
button,
input,
select,
textarea {
font: inherit;
}
button {
cursor: pointer;
-webkit-tap-highlight-color: transparent;
}
button {
color: inherit;
}
button,
input,
textarea,
select,
a {
outline: none;
}
button:focus-visible,
a:focus-visible,
.chart-plot:focus-visible {
outline: 3px solid #b6a4ee;
outline-offset: 3px;
}
button {
transition:
background 0.18s ease,
color 0.18s ease,
border-color 0.18s ease,
box-shadow 0.18s ease,
transform 0.18s ease;
}
button:disabled {
cursor: not-allowed;
opacity: 0.35;
}
button:active:not(:disabled) {
transform: translateY(1px);
}
input,
select,
textarea {
min-width: 0;
color: var(--ink);
}
input::placeholder,
textarea::placeholder {
color: #b0afbb;
opacity: 1;
}
input,
select,
textarea {
transition:
border-color 0.18s,
box-shadow 0.18s;
}
h1,
h2,
h3,
p {
margin: 0;
}
button svg,
span svg {
flex-shrink: 0;
}
::selection {
background: #e3dafa;
color: #503b8b;
}
::-webkit-scrollbar {
width: 6px;
height: 5px;
}
::-webkit-scrollbar-thumb {
border-radius: 10px;
background: #d7d4e2;
}
::-webkit-scrollbar-track {
background: transparent;
}
.sr-only {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
border: 0;
}
@keyframes enter {
from {
opacity: 0;
transform: translateY(10px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
@keyframes modal-enter {
from {
opacity: 0;
transform: translateY(15px) scale(0.985);
}
to {
opacity: 1;
transform: translateY(0) scale(1);
}
}
@keyframes fade-in {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
@keyframes line-draw {
from {
stroke-dashoffset: 1;
}
to {
stroke-dashoffset: 0;
}
}
@keyframes ring-enter {
from {
opacity: 0;
transform: scale(0.92) rotate(-8deg);
}
to {
opacity: 1;
transform: scale(1) rotate(0);
}
}
}
@layer components {
.app-shell {
min-height: 100vh;
}
.sidebar {
position: fixed;
width: var(--sidebar);
top: 0;
left: 0;
bottom: 0;
z-index: 40;
background: #fff;
border-right: 1px solid #eaeaf0;
padding: 27px 18px 0;
display: flex;
flex-direction: column;
}
.sidebar-top {
display: flex;
align-items: center;
justify-content: space-between;
}
.brand-button {
border: none;
background: none;
padding: 0 10px;
}
.brand {
display: flex;
gap: 10px;
align-items: center;
font-size: 32px;
line-height: 1;
font-weight: 800;
letter-spacing: -1.7px;
}
.brand-icon {
display: grid;
place-items: center;
background: var(--primary);
color: #fff;
width: 36px;
height: 36px;
border-radius: 11px;
box-shadow: 0 4px 10px #8067db15;
}
.brand-dot {
color: var(--primary);
}
.workspace-picker {
display: flex;
align-items: center;
gap: 9px;
background: #fafafb;
border: 1px solid #eaeaf0;
border-radius: 10px;
padding: 11px 9px;
margin-top: 32px;
width: 100%;
text-align: left;
}
.workspace-picker:hover {
background: #f4f1fb;
border-color: #ddd4f5;
}
.workspace-symbol {
background: #fff;
border: 1px solid #eae8ef;
color: #898098;
display: grid;
place-items: center;
width: 31px;
height: 33px;
border-radius: 8px;
flex-shrink: 0;
}
.workspace-picker > span:nth-child(2) {
min-width: 0;
flex: 1;
}
.workspace-picker strong {
display: block;
font-size: 11px;
font-weight: 700;
line-height: 19px;
white-space: nowrap;
text-overflow: ellipsis;
overflow: hidden;
}
.workspace-picker small {
display: block;
font-size: 9px;
color: #a3a0ad;
line-height: 15px;
}
.workspace-picker > svg {
color: #9d98aa;
}
.nav-label {
font-size: 9px;
font-weight: 600;
color: #aaa7b4;
letter-spacing: 1.3px;
margin: 29px 12px 12px;
}
.main-nav {
display: flex;
flex-direction: column;
gap: 6px;
}
.nav-item {
display: flex;
align-items: center;
gap: 12px;
border: 0;
background: transparent;
width: 100%;
text-align: left;
border-radius: 9px;
padding: 13px 13px;
font-size: 12px;
font-weight: 500;
color: #898492;
}
.nav-item > span:nth-child(2) {
flex: 1;
}
.nav-item:hover {
background: #f6f3fc;
color: var(--primary);
}
.nav-item.active {
color: #7b62d4;
background: #eeebf9;
font-weight: 700;
}
.nav-active-dot {
width: 5px;
height: 5px;
background: #8f75df;
border-radius: 50%;
margin-right: 3px;
}
.nav-count {
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 21px;
height: 20px;
padding: 0 5px;
color: #aaa4b4;
background: #f2f1f5;
font-size: 10px;
border-radius: 5px;
}
.nav-item.active .nav-count {
color: #8a73cf;
background: #e3dcf6;
}
.sidebar-bottom {
margin-top: auto;
padding-top: 28px;
}
.calm-card {
position: relative;
background: linear-gradient(127deg, #f6f2fd, #eeebf8);
border: 1px solid #eee8f7;
border-radius: 12px;
padding: 17px 18px 0;
margin: 0 0 20px;
overflow: hidden;
}
.calm-flower {
width: 49px;
height: 49px;
margin-bottom: 10px;
}
.calm-spark {
position: absolute;
top: 33px;
right: 29px;
font-size: 14px;
color: #bba9df;
}
.calm-card h3 {
font-size: 14px;
line-height: 23px;
font-weight: 700;
letter-spacing: -0.3px;
color: #655482;
}
.calm-card p {
font-size: 10px;
line-height: 18px;
margin-top: 8px;
color: #aaa0ba;
}
.calm-footer {
border-top: 1px solid #e7def3;
margin-top: 16px;
padding: 12px 0;
display: flex;
gap: 7px;
align-items: center;
font-size: 9px;
color: #9f8bbb;
}
.calm-footer .tiny-dot {
background: #a58bcf;
width: 4px;
height: 4px;
}
.help-nav {
padding: 12px 9px 18px;
font-size: 10px;
gap: 9px;
}
.help-nav > svg:last-child {
color: #b6b0c0;
}
.profile {
border-top: 1px solid #efedf2;
display: flex;
align-items: center;
gap: 9px;
padding: 20px 2px;
}
.avatar,
.top-avatar {
display: grid;
place-items: center;
width: 34px;
height: 34px;
border-radius: 50%;
background: #ede8e3;
color: #8d7969;
font-size: 13px;
font-weight: 600;
}
.avatar {
box-shadow: 0 0 0 3px #f8f5f3;
width: 31px;
height: 31px;
}
.profile > span:nth-child(2) {
flex: 1;
}
.profile strong {
font-size: 10px;
display: block;
font-weight: 600;
line-height: 18px;
}
.profile small {
font-size: 8px;
color: #a7a2ae;
display: flex;
gap: 4px;
align-items: center;
line-height: 15px;
white-space: nowrap;
}
.profile small i {
display: block;
width: 4px;
height: 4px;
background: #8cad9a;
border-radius: 50%;
}
.profile .icon-button {
color: #8d869c;
}
.main-shell {
margin-left: var(--sidebar);
}
.topbar {
height: 71px;
background: #fff;
border-bottom: 1px solid var(--line);
display: flex;
align-items: center;
justify-content: space-between;
gap: 20px;
padding: 0 35px;
}
.breadcrumb {
display: flex;
align-items: center;
gap: 15px;
font-size: 10px;
color: #a8a4af;
white-space: nowrap;
}
.breadcrumb svg {
color: #bbb5c5;
}
.breadcrumb strong {
font-size: 10px;
font-weight: 600;
color: #676270;
}
.topbar-right {
display: flex;
align-items: center;
gap: 19px;
}
.global-search {
display: flex;
align-items: center;
gap: 8px;
color: #aaa5b3;
padding: 7px 0 7px 9px;
border-radius: 7px;
border: 1px solid transparent;
transition:
background 0.2s,
border-color 0.2s;
}
.global-search:focus-within,
.global-search.has-value {
background: #f9f7fd;
border-color: #e7dff7;
padding-right: 8px;
}
.global-search input {
width: 130px;
background: transparent;
border: 0;
font-size: 10px;
padding: 0;
}
.global-search input::placeholder {
color: #aaa5b3;
}
kbd {
background: #f7f6f9;
border: 1px solid #eceaf0;
border-radius: 4px;
padding: 2px 4px;
color: #a6a0b0;
font-family: inherit;
font-size: 9px;
line-height: 14px;
white-space: nowrap;
}
.global-search button {
border: 0;
background: none;
display: flex;
padding: 0;
color: #a39bab;
}
.topbar-divider {
height: 21px;
width: 1px;
background: #eeebf1;
}
.today {
display: flex;
align-items: center;
gap: 8px;
font-size: 10px;
color: #89828f;
}
.today svg {
color: #a69fac;
}
.top-avatar {
height: 30px;
width: 30px;
border: 3px solid #fff;
box-sizing: content-box;
box-shadow: 0 0 0 1px #ede8e5;
font-size: 11px;
}
.top-avatar:hover {
box-shadow: 0 0 0 2px #c9b6ea;
}
.main-content {
padding: 29px 35px 0;
max-width: 1540px;
margin: 0 auto;
animation: enter 0.4s ease both;
}
.page-heading {
display: flex;
justify-content: space-between;
align-items: center;
gap: 18px;
margin-bottom: 25px;
}
.page-eyebrow {
display: none;
}
h1 {
font-size: 30px;
letter-spacing: -1.15px;
line-height: 1.4;
font-weight: 750;
}
.heading-dot {
color: #9478e0;
margin-left: 1px;
}
.page-heading p {
font-size: 11px;
color: #9e97a5;
margin-top: 6px;
letter-spacing: 0.04px;
}
.page-actions {
display: flex;
align-items: center;
gap: 9px;
padding-top: 6px;
}
.button {
border-radius: 8px;
border: 1px solid transparent;
padding: 11px 16px;
display: inline-flex;
justify-content: center;
align-items: center;
gap: 8px;
min-height: 39px;
line-height: 17px;
font-size: 11px;
font-weight: 600;
white-space: nowrap;
}
.button.primary {
background: var(--primary);
color: white;
border-color: #7c62d6;
box-shadow:
0 3px 7px #7962ca12,
inset 0 1px 0 #ffffff19;
}
.button.primary:hover {
background: var(--primary-dark);
border-color: var(--primary-dark);
box-shadow: 0 5px 15px #8067db25;
transform: translateY(-1px);
}
.button.secondary {
background: #fff;
color: #736d7e;
border-color: #e6e3ec;
box-shadow: 0 2px 2px #27203302;
}
.button.secondary:hover {
background: #fbf9fe;
border-color: #d7cdea;
color: #775cb4;
}
.button.small {
padding: 8px 13px;
min-height: 34px;
font-size: 10px;
}
.button.danger {
background: #c4696b;
border-color: #c4696b;
color: white;
}
.button.danger:hover {
background: #b55558;
}
.button.danger-ghost {
color: #b87178;
background: #fbf2f3;
}
.button.danger-ghost:hover {
background: #f7e6e9;
}
.full-width {
width: 100%;
}
.icon-button {
display: inline-flex;
justify-content: center;
align-items: center;
width: 34px;
height: 34px;
background: none;
border: 1px solid transparent;
border-radius: 7px;
padding: 0;
color: #9690a0;
flex-shrink: 0;
}
.icon-button:hover,
.icon-button.selected {
color: var(--primary);
background: #f1ecfa;
}
.icon-button.tiny {
width: 28px;
height: 28px;
}
.text-button {
border: 0;
background: none;
color: #8b74c6;
font-size: 10px;
font-weight: 600;
display: flex;
align-items: center;
gap: 8px;
padding: 6px 0 6px 6px;
}
.text-button:hover {
color: #6244ae;
gap: 12px;
}
.period-toolbar {
display: flex;
align-items: center;
justify-content: space-between;
gap: 14px;
margin-bottom: 20px;
}
.period-controls {
display: inline-flex;
padding: 4px;
background: #edeef2;
border: 1px solid #e9eaef;
border-radius: 9px;
gap: 2px;
}
.period-controls button {
background: none;
border: 1px solid transparent;
border-radius: 6px;
padding: 7px 16px;
font-size: 10px;
font-weight: 500;
color: #9993a2;
line-height: 16px;
}
.period-controls button:hover {
color: #736089;
background: #ffffff55;
}
.period-controls button.active {
background: #fff;
border-color: #e4e3e9;
color: #746284;
font-weight: 700;
box-shadow: 0 1px 4px #44365009;
}
.date-range {
display: flex;
align-items: center;
gap: 8px;
font-size: 10px;
color: #938b9b;
}
.date-range svg {
color: #a59cae;
}
.stats-grid {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 18px;
margin-bottom: 21px;
}
.stat-card {
position: relative;
border: 1px solid #e7e5ef;
border-radius: 12px;
background: #fff;
padding: 17px 21px 14px;
overflow: hidden;
min-height: 144px;
box-shadow: 0 2px 3px #34314101;
}
.stat-card:nth-child(2) {
animation: enter 0.5s 0.04s backwards;
}
.stat-card:nth-child(3) {
animation: enter 0.5s 0.08s backwards;
}
.balance-card {
background: linear-gradient(108deg, #f0ebfb 0%, #eee9fb 50%, #eae3f7 100%);
border-color: #e0d7f2;
}
.balance-art {
position: absolute;
top: -3px;
right: -5px;
fill: none;
stroke: #bca9dd;
stroke-width: 0.75;
opacity: 0.22;
pointer-events: none;
}
.stat-top {
position: relative;
display: flex;
align-items: center;
justify-content: space-between;
color: #9a91a3;
font-size: 11px;
height: 26px;
}
.stat-top > span:first-child {
display: flex;
align-items: center;
gap: 6px;
}
.balance-card .stat-top {
color: #8d7caa;
}
.info-tip {
display: inline-flex;
align-items: center;
opacity: 0.65;
cursor: help;
}
.stat-icon {
display: grid;
place-items: center;
width: 30px;
height: 30px;
border-radius: 9px;
}
.stat-icon.purple {
color: #9e86c8;
background: #ffffff85;
border: 1px solid #f6f1fe;
}
.stat-icon.green {
color: #6caa92;
background: #eef6f2;
border: 1px solid #eaf2ee;
}
.stat-icon.peach {
color: #d1a184;
background: #faf1eb;
border: 1px solid #f7ede6;
}
.money {
position: relative;
display: block;
margin-top: 9px;
font-size: 31px;
font-weight: 700;
line-height: 43px;
letter-spacing: -0.8px;
white-space: nowrap;
font-variant-numeric: tabular-nums;
}
.money-decimals {
font-size: 24px;
color: #b0a9b9;
font-weight: 500;
letter-spacing: -0.5px;
}
.money-symbol {
margin-left: 8px;
font-size: 24px;
font-weight: 400;
color: #a9a2b1;
}
.balance-card .money {
color: #554668;
}
.balance-card .money-decimals,
.balance-card .money-symbol {
color: #a696bb;
}
.stat-bottom {
height: 29px;
margin-top: 4px;
display: flex;
align-items: center;
justify-content: space-between;
}
.balance-status {
display: inline-flex;
align-items: center;
gap: 5px;
font-size: 9px;
color: #9684ab;
background: #e7dff26b;
border: 1px solid #ddcfec66;
border-radius: 5px;
line-height: 19px;
padding: 0 6px;
}
.balance-status.positive {
color: #628d7e;
background: #e5f0e970;
border-color: #dbe8e160;
}
.stat-meta {
display: flex;
align-items: center;
gap: 6px;
font-size: 9px;
color: #a29ba9;
}
.tiny-dot {
display: inline-block;
width: 5px;
height: 5px;
border-radius: 50%;
flex-shrink: 0;
}
.green-dot {
background: #87af9d;
}
.peach-dot {
background: #ddaf93;
}
.sparkline {
opacity: 0.85;
margin-right: -2px;
margin-top: -7px;
}
.charts-grid {
display: grid;
grid-template-columns: minmax(0, 2.08fr) minmax(270px, 1fr);
gap: 20px;
margin-bottom: 21px;
align-items: stretch;
}
.card {
background: #fff;
border: 1px solid #e7e5ee;
border-radius: 12px;
box-shadow: 0 2px 3px #34294302;
}
.card-heading {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 12px;
padding: 21px 22px 0;
}
.card-heading h2 {
font-size: 13px;
line-height: 21px;
font-weight: 700;
letter-spacing: -0.25px;
display: flex;
align-items: center;
gap: 8px;
}
.card-heading p {
font-size: 9px;
line-height: 16px;
color: #aaa0b0;
margin-top: 4px;
}
.title-dot {
width: 5px;
height: 5px;
background: #c0addb;
border-radius: 50%;
}
.chart-tabs {
display: flex;
gap: 1px;
align-self: center;
background: #f7f6f9;
border: 1px solid #f0edf4;
border-radius: 7px;
padding: 3px;
flex-shrink: 0;
}
.chart-tabs button {
background: transparent;
border: 1px solid transparent;
color: #a197a9;
border-radius: 5px;
font-size: 9px;
font-weight: 500;
padding: 6px 9px;
line-height: 13px;
}
.chart-tabs button.active {
color: #9479bb;
background: #fff;
box-shadow: 0 1px 3px #72578a08;
border-color: #e9e2f0;
font-weight: 600;
}
.chart-tabs button:hover:not(.active) {
color: var(--primary);
background: #f0eaf7;
}
.chart-legend {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 22px;
margin: 17px 22px 6px;
}
.legend-item {
display: inline-flex;
align-items: center;
gap: 6px;
font-size: 9px;
color: #a195ab;
white-space: nowrap;
}
.legend-item i,
.breakdown-legend i,
.chart-tooltip i {
display: inline-block;
width: 6px;
height: 6px;
border-radius: 50%;
flex-shrink: 0;
}
.legend-item b {
font-size: 10px;
color: #7e6c88;
font-weight: 600;
margin-left: 2px;
}
.chart-interval {
margin-left: auto;
display: inline-flex;
align-items: center;
gap: 6px;
font-size: 8px;
color: #b1a9bb;
}
.chart-plot {
position: relative;
margin: 5px 15px 5px 10px;
border-radius: 4px;
}
.chart-plot > svg {
display: block;
overflow: visible;
}
.axis-label {
fill: #b1a7bc;
font-size: 8px;
font-family: "Manrope", sans-serif;
font-weight: 400;
}
.chart-line {
stroke-dasharray: 1;
animation: line-draw 1.3s cubic-bezier(0.22, 0.61, 0.36, 1) both;
}
.chart-area {
animation: fade-in 1.5s 0.15s both;
}
.chart-tooltip {
position: absolute;
min-width: 192px;
pointer-events: none;
z-index: 5;
border: 1px solid #eae2f0;
box-shadow: 0 7px 28px #43325212;
background: #fffffff5;
backdrop-filter: blur(8px);
border-radius: 9px;
padding: 11px 13px;
animation: fade-in 0.12s ease;
}
.chart-tooltip strong {
font-size: 9px;
color: #675a75;
display: block;
padding-bottom: 8px;
margin-bottom: 5px;
border-bottom: 1px solid #f0ebf3;
}
.chart-tooltip > div {
display: flex;
align-items: center;
justify-content: space-between;
gap: 13px;
font-size: 9px;
padding: 3px 0;
}
.chart-tooltip > div span {
display: flex;
align-items: center;
gap: 6px;
color: #9f91ac;
}
.chart-tooltip b {
color: #665672;
font-weight: 600;
}
.chart-footnote {
margin: 3px 22px 0;
padding: 11px 0 12px;
display: flex;
align-items: center;
gap: 4px;
font-size: 9px;
color: #ab9fb5;
border-top: 1px solid #f0ecf4;
min-height: 39px;
flex-wrap: wrap;
}
.chart-footnote strong {
font-weight: 600;
color: #8d7a9e;
}
.footnote-icon {
display: inline-flex;
margin-right: 3px;
color: #b09bce;
}
.breakdown-card {
display: flex;
flex-direction: column;
}
.breakdown-card .card-heading {
gap: 2px;
}
.breakdown-card .icon-button {
margin-top: -3px;
margin-right: -5px;
}
.donut-wrap {
position: relative;
width: 156px;
height: 156px;
margin: 7px auto 6px;
flex-shrink: 0;
}
.donut {
width: 100%;
height: 100%;
overflow: visible;
animation: ring-enter 0.9s 0.1s cubic-bezier(0.23, 1, 0.32, 1) both;
}
.donut-segment {
cursor: pointer;
transition:
stroke-width 0.2s,
opacity 0.2s;
}
.donut-center {
position: absolute;
inset: 0;
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
pointer-events: none;
gap: 4px;
}
.donut-center > span {
font-size: 21px;
letter-spacing: -0.7px;
font-weight: 700;
color: #655674;
font-variant-numeric: tabular-nums;
}
.donut-center small {
font-size: 8px;
color: #ab9bb8;
max-width: 103px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.breakdown-legend {
display: flex;
flex-direction: column;
gap: 2px;
margin: 0 25px 11px;
}
.breakdown-legend button {
display: flex;
justify-content: space-between;
align-items: center;
border: 0;
background: transparent;
border-radius: 4px;
padding: 5px 0;
font-size: 9px;
line-height: 14px;
}
.breakdown-legend button > span {
display: inline-flex;
align-items: center;
gap: 8px;
color: #92839e;
text-align: left;
}
.breakdown-legend button > b {
font-weight: 600;
color: #75627f;
letter-spacing: 0.1px;
}
.breakdown-legend button > b > span {
font-size: 8px;
color: #b2a5bb;
margin-left: 2px;
font-weight: 500;
}
.breakdown-legend button:hover {
background: #faf7fe;
transform: translateX(2px);
}
.breakdown-footer {
margin: auto 22px 0;
padding: 12px 0;
border-top: 1px solid #f0ecf4;
color: #b1a7bb;
font-size: 8px;
display: flex;
align-items: center;
justify-content: space-between;
}
.empty-donut-text {
font-size: 10px;
line-height: 19px;
text-align: center;
color: #aea1b8;
padding: 3px 0;
}
.transactions-card {
position: relative;
}
.transactions-card .card-heading {
align-items: center;
padding-top: 19px;
padding-bottom: 17px;
}
.transactions-card .card-heading p {
margin-top: 3px;
}
.count-badge {
display: inline-flex;
justify-content: center;
align-items: center;
font-size: 8px;
line-height: 16px;
height: 17px;
padding: 0 5px;
min-width: 18px;
color: #ac9fba;
background: #f6f2fb;
border: 1px solid #efe8f5;
border-radius: 4px;
margin-left: 2px;
font-weight: 500;
}
.table-scroll {
width: 100%;
}
table {
width: 100%;
border-collapse: collapse;
border-spacing: 0;
text-align: left;
}
thead {
background: #fcfbfd;
}
th {
padding: 10px 16px;
border-top: 1px solid #f2eef5;
border-bottom: 1px solid #f2eef5;
color: #b0a4bb;
font-size: 8px;
letter-spacing: 0.7px;
text-transform: uppercase;
font-weight: 500;
line-height: 13px;
white-space: nowrap;
}
th:first-child,
td:first-child {
padding-left: 23px;
width: 33%;
}
th:nth-child(2) {
width: 28%;
}
th:nth-child(3) {
width: 18%;
}
th:nth-child(4) {
width: 17%;
}
th button {
border: 0;
background: none;
font: inherit;
color: inherit;
text-transform: inherit;
letter-spacing: inherit;
display: inline-flex;
align-items: center;
gap: 7px;
padding: 0;
}
th button:hover {
color: #7e5bad;
}
.actions-head {
width: 44px;
}
td {
padding: 11px 16px;
border-bottom: 1px solid #f2eef5;
vertical-align: middle;
font-size: 10px;
}
tbody tr:last-child td {
border-bottom: 0;
}
tbody tr {
transition: background 0.17s ease;
}
tbody tr:hover {
background: #fdfbff;
}
.operation-name {
display: flex;
align-items: center;
gap: 11px;
text-align: left;
border: 0;
background: transparent;
padding: 0;
width: 100%;
}
.operation-name:hover strong {
color: #8b6dc1;
}
.category-icon {
display: inline-flex;
align-items: center;
justify-content: center;
width: 34px;
height: 34px;
border-radius: 9px;
flex-shrink: 0;
}
.operation-name > span:last-child {
min-width: 0;
}
.operation-name strong {
display: block;
font-size: 10px;
font-weight: 600;
color: #756280;
white-space: normal;
overflow-wrap: anywhere;
line-height: 16px;
transition: color 0.18s;
}
.operation-name small {
display: block;
font-size: 8px;
color: #b3a7bd;
line-height: 14px;
margin-top: 1px;
}
.category-label {
font-size: 8px;
color: #a292ad;
background: #f8f5fc;
border: 1px solid #f0eaf6;
padding: 4px 8px;
border-radius: 5px;
white-space: nowrap;
}
.date-cell {
color: #9d8eaa;
font-size: 9px;
white-space: nowrap;
}
.date-cell span {
color: #b3a7be;
}
.amount-cell {
text-align: right;
}
th.amount-cell button {
justify-content: flex-end;
}
.transaction-amount {
font-size: 10px;
font-weight: 650;
color: #85738f;
white-space: nowrap;
font-variant-numeric: tabular-nums;
letter-spacing: -0.1px;
}
.transaction-amount.income {
color: #73a58e;
}
.row-actions {
position: relative;
display: inline-flex;
}
td:last-child {
padding-left: 0;
padding-right: 18px;
text-align: right;
}
.row-actions > button {
color: #b4a6c0;
}
.row-menu {
position: absolute;
right: 4px;
bottom: 29px;
min-width: 156px;
border: 1px solid #e9e0f1;
border-radius: 8px;
background: #fff;
padding: 5px;
z-index: 12;
box-shadow: 0 8px 25px #58406e14;
animation: enter 0.15s ease;
}
.row-menu button {
display: flex;
align-items: center;
text-align: left;
gap: 8px;
padding: 10px;
font-size: 10px;
width: 100%;
background: none;
border: 0;
border-radius: 4px;
color: #8e779e;
}
.row-menu button:hover {
background: #f7f0fd;
}
.danger-text {
color: #c67d84 !important;
}
.table-footer {
border-top: 1px solid #f0eaf6;
display: flex;
justify-content: space-between;
align-items: center;
font-size: 8px;
color: #b5a8c1;
padding: 12px 23px;
min-height: 39px;
}
.saved-status {
display: flex;
align-items: center;
gap: 5px;
color: #aaa1b2;
}
.saved-status svg {
color: #8fb7a2;
}
.pagination {
display: flex;
align-items: center;
gap: 10px;
}
.pagination span {
color: #8f76a0;
font-size: 10px;
}
.empty-state {
padding: 43px 22px 48px;
text-align: center;
border-top: 1px solid #f1ebf7;
}
.empty-icon {
width: 61px;
height: 61px;
border-radius: 19px;
display: inline-flex;
align-items: center;
justify-content: center;
color: #a28bc4;
background: #f4eefb;
margin-bottom: 15px;
}
.empty-state h3 {
color: #7e668f;
font-size: 16px;
margin-bottom: 9px;
}
.empty-state p {
color: #ab9bb7;
font-size: 11px;
line-height: 20px;
}
.empty-state > div {
display: flex;
justify-content: center;
flex-wrap: wrap;
gap: 9px;
margin-top: 20px;
}
.transaction-filters {
display: flex;
justify-content: space-between;
align-items: center;
gap: 16px;
margin-bottom: 20px;
padding: 15px 0 0;
border-top: 1px solid #e9e3ef;
}
.type-filter {
display: inline-flex;
gap: 5px;
}
.type-filter button {
display: flex;
align-items: center;
gap: 6px;
border: 1px solid transparent;
background: none;
color: #a495b1;
border-radius: 7px;
font-size: 10px;
font-weight: 500;
padding: 10px 14px;
}
.type-filter button.active {
color: #9477c4;
background: #ece5f9;
border-color: #e4daf3;
font-weight: 600;
}
.type-filter button:hover {
color: #8765b6;
background: #f3eef9;
}
.filter-select {
display: flex;
gap: 8px;
align-items: center;
background: white;
border: 1px solid #e8dfef;
padding: 10px 12px;
border-radius: 7px;
color: #b2a1bf;
}
.filter-select select {
border: 0;
background: none;
appearance: none;
color: #9a85a8;
font-size: 10px;
max-width: 175px;
padding-right: 8px;
cursor: pointer;
}
.active-filters {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
margin: -3px 0 17px;
color: #ad9cba;
font-size: 10px;
}
.active-filters button {
border: 0;
background: none;
padding: 4px 0;
display: flex;
align-items: center;
gap: 4px;
color: #9c7cbb;
font-size: 9px;
}
.filtered-summary {
display: flex;
justify-content: space-between;
gap: 20px;
padding: 20px 6px;
font-size: 9px;
color: #aa98b8;
}
.filtered-summary > div {
display: flex;
gap: 24px;
}
.filtered-summary > div span {
display: flex;
flex-direction: column;
gap: 6px;
}
.filtered-summary b {
color: #7e658f;
font-size: 13px;
font-weight: 600;
}
.income-text {
color: #72a089 !important;
}
.breakdown-type {
display: flex;
padding: 3px;
background: #f8f4fc;
border: 1px solid #eee5f6;
border-radius: 6px;
align-self: center;
margin-top: 12px;
}
.breakdown-type button {
background: transparent;
border: none;
color: #ac98bb;
font-size: 9px;
padding: 6px 19px;
border-radius: 4px;
}
.breakdown-type button.active {
background: #fff;
box-shadow: 0 1px 3px #6941860a;
color: #9274b6;
}
.analytics-charts .donut-wrap {
margin-top: 16px;
margin-bottom: 14px;
width: 172px;
height: 172px;
}
.insights-grid {
display: grid;
grid-template-columns: minmax(0, 2.08fr) minmax(270px, 1fr);
gap: 20px;
}
.comparison-card {
padding-bottom: 5px;
}
.muted {
color: #bda9cf;
}
.comparison-head,
.comparison-row {
display: grid;
grid-template-columns: 1fr 0.85fr 0.85fr;
gap: 15px;
margin: 0 22px;
align-items: center;
}
.comparison-head {
padding: 17px 0 12px;
font-size: 9px;
color: #bba9c8;
border-bottom: 1px solid #f1e9f8;
}
.comparison-head > :not(:first-child),
.comparison-row > :not(:first-child) {
text-align: right;
}
.comparison-row {
padding: 15px 0;
border-bottom: 1px solid #f2eafa;
font-size: 11px;
color: #ae99bd;
}
.comparison-row:last-child {
border-bottom: 0;
}
.comparison-row > span:first-child {
display: flex;
gap: 10px;
align-items: center;
color: #9379a5;
}
.comparison-row strong {
font-weight: 600;
color: #8a6d9e;
}
.insight-card {
background: linear-gradient(140deg, #f2edf9, #f9f5fb);
padding: 20px 22px;
border-color: #e9def3;
}
.insight-symbol {
display: flex;
width: 33px;
height: 33px;
align-items: center;
justify-content: center;
border: 1px solid #e5d6f0;
border-radius: 9px;
background: #fff9;
color: #a085c4;
margin-bottom: 11px;
}
.insight-card h2 {
font-size: 14px;
color: #9271a5;
font-weight: 700;
margin-bottom: 19px;
}
.insight-line {
display: flex;
justify-content: space-between;
gap: 15px;
font-size: 9px;
padding: 11px 0;
border-bottom: 1px solid #e9dfee;
}
.insight-line > span {
color: #af98c0;
}
.insight-line strong {
color: #9675a9;
font-size: 11px;
font-weight: 600;
text-align: right;
}
.insight-line small {
display: block;
font-size: 8px;
font-weight: 400;
color: #b09ac0;
padding-top: 4px;
}
.insight-card p {
font-size: 8px;
line-height: 16px;
color: #b09cbf;
margin-top: 14px;
}
.insight-card p .tiny-dot {
margin-right: 5px;
width: 4px;
height: 4px;
}
.categories-top {
display: flex;
justify-content: space-between;
align-items: center;
gap: 16px;
margin: 25px 0 20px;
}
.categories-top p {
font-size: 9px;
color: #b39dc0;
}
.categories-grid {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 20px;
}
.category-card {
padding: 21px;
transition:
box-shadow 0.2s,
border-color 0.2s,
transform 0.2s;
}
.category-card:hover {
box-shadow: 0 8px 25px #71508b09;
border-color: #deceed;
transform: translateY(-2px);
}
.category-card-top {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 17px;
}
.category-card .category-icon {
width: 43px;
height: 43px;
border-radius: 12px;
}
.category-card h2 {
font-size: 13px;
font-weight: 650;
color: #9371a3;
}
.category-card-count {
font-size: 9px;
color: #bba6c6;
display: block;
margin-top: 6px;
}
.category-total {
display: flex;
justify-content: space-between;
align-items: center;
width: 100%;
border: 0;
background: none;
font-size: 24px;
font-weight: 600;
color: #9371a3;
padding: 21px 0 16px;
letter-spacing: -0.6px;
}
.category-total svg {
color: #bfaad0;
transition: transform 0.2s;
}
.category-total:hover svg {
color: #8565ab;
transform: translate(2px, -2px);
}
.category-progress {
height: 4px;
background: #f4effa;
border-radius: 3px;
overflow: hidden;
}
.category-progress span {
height: 100%;
display: block;
border-radius: 3px;
transition: width 0.7s ease;
}
.category-share {
display: flex;
justify-content: space-between;
margin-top: 10px;
font-size: 8px;
color: #bca5cc;
}
.category-share b {
font-weight: 500;
color: #a789bb;
}
.new-category-card {
border: 1px dashed #dccce9;
background: transparent;
border-radius: 12px;
display: flex;
flex-direction: column;
gap: 10px;
align-items: center;
justify-content: center;
min-height: 249px;
}
.new-category-card > span {
display: flex;
justify-content: center;
align-items: center;
color: #b496cf;
background: #f0e8f9;
width: 43px;
height: 43px;
border-radius: 12px;
margin-bottom: 3px;
}
.new-category-card strong {
font-size: 12px;
color: #ac8dc0;
font-weight: 500;
}
.new-category-card small {
font-size: 9px;
color: #c1acd0;
}
.new-category-card:hover {
background: #f6effb;
border-color: #b79bce;
}
.page-footer {
display: flex;
justify-content: space-between;
gap: 15px;
font-size: 8px;
color: #b6acbf;
padding: 23px 2px 25px;
}
.page-footer > span:last-child {
display: flex;
align-items: center;
gap: 6px;
}
.page-footer i {
display: inline-block;
width: 3px;
height: 3px;
border-radius: 50%;
background: #ccc0d6;
margin: 0 6px;
}
.storage-warning {
display: flex;
align-items: center;
gap: 10px;
background: #fbecdd;
border: 1px solid #ecd6bd;
color: #ac8560;
font-size: 11px;
line-height: 20px;
border-radius: 9px;
padding: 12px 15px;
margin-bottom: 18px;
}
.storage-warning svg {
flex-shrink: 0;
}
.modal-overlay {
position: fixed;
inset: 0;
z-index: 100;
background: #2d253c48;
backdrop-filter: blur(5px);
display: flex;
align-items: center;
justify-content: center;
padding: 24px;
overflow-y: auto;
animation: fade-in 0.2s ease;
}
.modal {
background: #fff;
border: 1px solid #f2eafa;
box-shadow: 0 25px 100px #31243e30;
border-radius: 19px;
width: 470px;
max-width: 100%;
padding: 27px;
animation: modal-enter 0.3s cubic-bezier(0.2, 0.75, 0.25, 1) both;
max-height: calc(100dvh - 48px);
overflow-y: auto;
}
.modal.wide {
width: 580px;
}
.modal-heading {
display: flex;
gap: 12px;
align-items: flex-start;
justify-content: space-between;
margin-bottom: 23px;
}
.modal-heading h2 {
color: #574564;
font-size: 21px;
font-weight: 700;
line-height: 30px;
letter-spacing: -0.65px;
}
.modal-heading p {
color: #ae9cbb;
font-size: 10px;
line-height: 18px;
margin-top: 5px;
}
.modal-close {
margin-top: -4px;
margin-right: -8px;
color: #baa8c7;
}
.type-switch {
display: flex;
background: #f8f5fb;
border: 1px solid #eee6f5;
border-radius: 9px;
padding: 4px;
margin-bottom: 18px;
}
.type-switch button {
flex: 1;
display: flex;
align-items: center;
justify-content: center;
gap: 7px;
padding: 10px;
border-radius: 6px;
border: 1px solid transparent;
background: none;
color: #b5a0c4;
font-size: 11px;
font-weight: 600;
}
.type-switch button.active {
background: #fff;
border-color: #ede3f5;
box-shadow: 0 2px 5px #653c7807;
}
.type-switch button.active.expense {
color: #c99980;
}
.type-switch button.active.income {
color: #79a28d;
}
.amount-input {
display: block;
background: #faf8fc;
border: 1px solid #efe8f6;
border-radius: 11px;
padding: 17px 19px;
margin-bottom: 20px;
transition:
border-color 0.2s,
box-shadow 0.2s;
}
.amount-input:focus-within {
border-color: #b9a0dc;
box-shadow: 0 0 0 3px #eee4fb;
}
.amount-input > span {
font-size: 9px;
color: #b29ac2;
}
.amount-input > div {
display: flex;
align-items: center;
gap: 8px;
margin-top: 9px;
}
.amount-input input {
background: none;
border: none;
padding: 0;
width: 100%;
font-size: 33px;
font-weight: 600;
letter-spacing: -0.7px;
line-height: 45px;
color: #755589;
}
.amount-input input::placeholder {
color: #c5b0d4;
font-weight: 500;
}
.amount-input > div > span {
font-size: 27px;
color: #bda4cf;
}
.field {
display: flex;
flex-direction: column;
gap: 8px;
margin-bottom: 17px;
min-width: 0;
}
.field > span {
font-size: 10px;
color: #9474a6;
font-weight: 600;
}
.field > span i {
font-style: normal;
color: #c0a2d3;
font-weight: 500;
}
.field > span small {
font-size: 9px;
font-weight: 400;
color: #bea7cd;
margin-left: 4px;
}
.field input,
.field select,
.field textarea {
width: 100%;
background: #fff;
border: 1px solid #e7daef;
border-radius: 7px;
padding: 11px 12px;
font-size: 11px;
color: #8d6c9f;
line-height: 18px;
}
.field input:focus,
.field select:focus,
.field textarea:focus {
border-color: #b196d0;
box-shadow: 0 0 0 3px #f2e9fb;
}
.field input::placeholder,
.field textarea::placeholder {
color: #c3afd0;
font-size: 10px;
}
.field input[type="date"] {
color-scheme: light;
padding-right: 9px;
}
.field input::-webkit-calendar-picker-indicator {
opacity: 0.35;
cursor: pointer;
}
.field textarea {
resize: vertical;
min-height: 64px;
max-height: 170px;
}
.field-hint {
color: #baa3c9;
font-size: 9px;
line-height: 16px;
}
.select-wrap {
position: relative;
}
.select-wrap select {
appearance: none;
padding-right: 31px;
cursor: pointer;
}
.select-wrap select:disabled {
background: #faf7fc;
color: #b39ac3;
cursor: not-allowed;
}
.select-wrap svg {
position: absolute;
right: 13px;
top: 50%;
transform: translateY(-50%);
pointer-events: none;
color: #bca1cd;
}
.form-row {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 14px;
}
.modal-footer {
display: flex;
justify-content: space-between;
gap: 12px;
padding-top: 19px;
margin-top: 7px;
border-top: 1px solid #f0e7f6;
}
.modal-footer .button {
min-height: 41px;
font-size: 10px;
}
.modal-footer .button:last-child {
margin-left: auto;
}
.form-privacy {
display: flex;
align-items: center;
justify-content: center;
gap: 5px;
font-size: 8px;
color: #c0aacc;
padding-top: 15px;
}
.form-error {
display: flex;
align-items: center;
gap: 7px;
background: #fcf0f0;
border-radius: 7px;
padding: 10px 11px;
color: #be7e85;
font-size: 10px;
line-height: 18px;
margin: 8px 0 15px;
}
.form-error svg {
flex-shrink: 0;
}
.category-preview {
display: flex;
flex-direction: column;
gap: 11px;
align-items: center;
justify-content: center;
padding: 3px 0 25px;
}
.category-preview .category-icon {
width: 61px;
height: 61px;
border-radius: 18px;
}
.category-preview strong {
font-size: 12px;
font-weight: 600;
color: #a484b9;
}
.color-options {
display: flex;
justify-content: space-between;
gap: 8px;
margin: 3px 0 2px;
}
.color-options button {
border: 3px solid white;
box-shadow: 0 0 0 1px #ece4f3;
border-radius: 50%;
width: 32px;
height: 32px;
display: flex;
align-items: center;
justify-content: center;
color: white;
flex-shrink: 0;
}
.color-options button[aria-pressed="true"] {
box-shadow: 0 0 0 2px #cfbde3;
}
.icon-options {
display: grid;
grid-template-columns: repeat(10, 1fr);
gap: 5px;
}
.icon-options button {
border: 1px solid #efe5f7;
border-radius: 7px;
background: #fcfaff;
display: flex;
justify-content: center;
align-items: center;
height: 34px;
color: #b19ac4;
padding: 0;
}
.icon-options button.active {
border-color: #bca0d7;
background: #efe4fa;
color: #8d6bad;
}
.privacy-box {
background: #f6f2fb;
border: 1px solid #ece1f6;
border-radius: 10px;
padding: 14px;
display: flex;
align-items: flex-start;
gap: 12px;
margin: 21px 0;
}
.privacy-box > svg {
color: #b095cc;
flex-shrink: 0;
margin-top: 2px;
}
.privacy-box strong {
color: #9978b1;
font-size: 10px;
font-weight: 600;
}
.privacy-box p {
color: #b69cc9;
font-size: 9px;
line-height: 17px;
margin-top: 5px;
}
.settings-section h3 {
display: flex;
align-items: center;
justify-content: space-between;
color: #a17eb4;
font-size: 11px;
font-weight: 600;
margin-bottom: 10px;
}
.settings-section h3 > span {
color: #c3abd1;
font-size: 8px;
font-weight: 400;
}
.data-actions {
border: 1px solid #eee3f6;
border-radius: 9px;
overflow: hidden;
}
.data-actions > button {
border: 0;
border-bottom: 1px solid #f1e8f7;
background: #fff;
padding: 12px;
display: flex;
align-items: center;
gap: 10px;
width: 100%;
text-align: left;
color: #af91c2;
}
.data-actions > button:nth-last-of-type(1) {
border-bottom: 0;
}
.data-actions > button:hover {
background: #fcf8ff;
}
.data-actions > button > span {
flex: 1;
font-size: 10px;
color: #a081b3;
}
.data-actions small {
display: block;
font-size: 8px;
color: #c2a8d1;
margin-top: 4px;
}
.settings-danger {
display: flex;
justify-content: space-between;
gap: 12px;
margin: 15px 0 6px;
}
.settings-danger button {
display: flex;
align-items: center;
gap: 6px;
border: 0;
background: none;
padding: 4px 0;
font-size: 9px;
color: #b9a0ca;
}
.settings-danger button:hover {
color: #9672af;
}
.confirm-modal {
width: 430px;
}
.confirm-modal .modal-heading {
margin-bottom: 15px;
}
.confirm-symbol {
width: 47px;
height: 47px;
border-radius: 14px;
background: #f1e8fa;
color: #ab8ac7;
display: grid;
place-items: center;
margin-bottom: 18px;
}
.confirm-symbol.danger {
background: #fbebee;
color: #c78897;
}
.confirm-description {
color: #ab93bc;
font-size: 11px;
line-height: 22px;
margin-bottom: 22px;
}
.help-item {
display: flex;
align-items: flex-start;
gap: 14px;
margin-bottom: 23px;
}
.help-item > span {
width: 39px;
height: 39px;
border-radius: 11px;
background: #f5edfd;
color: #b091cb;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
}
.help-item h3 {
font-size: 12px;
font-weight: 600;
color: #9a76b0;
margin-top: 2px;
}
.help-item p {
font-size: 10px;
line-height: 20px;
color: #b99ece;
margin-top: 6px;
}
.keyboard-hints {
display: flex;
gap: 24px;
border-top: 1px solid #f0e5f8;
padding: 17px 0 22px;
}
.keyboard-hints > span {
display: flex;
align-items: center;
gap: 7px;
font-size: 9px;
color: #bea4ce;
}
.keyboard-hints kbd {
background: #faf5ff;
border-color: #ecdef6;
color: #b59ac7;
}
.toast {
display: flex;
align-items: center;
gap: 11px;
max-width: calc(100vw - 32px);
position: fixed;
bottom: 28px;
left: calc(50% + var(--sidebar) / 2);
transform: translateX(-50%);
z-index: 200;
background: #fff;
border: 1px solid #e7ddef;
box-shadow: 0 8px 40px #3c2e5426;
padding: 15px 15px 15px 18px;
border-radius: 11px;
font-size: 11px;
color: #9575a9;
animation: fade-in 0.25s ease;
}
.toast > svg {
color: #8cb09c;
flex-shrink: 0;
}
.toast.error > svg {
color: #c48a85;
}
.toast button {
background: none;
border: 0;
display: flex;
margin-left: 10px;
padding: 3px;
color: #c4afd3;
}
.sidebar-backdrop,
.mobile-menu,
.mobile-close {
display: none;
}
}
@layer responsive {
@media (min-width: 1550px) {
:root {
--sidebar: 246px;
}
.sidebar {
padding-left: 23px;
padding-right: 23px;
}
.main-content {
padding-top: 35px;
padding-left: 43px;
padding-right: 43px;
}
.topbar {
padding-left: 43px;
padding-right: 43px;
}
.page-heading {
margin-bottom: 28px;
}
h1 {
font-size: 33px;
}
.page-heading p {
font-size: 12px;
}
.stat-card {
min-height: 158px;
padding: 21px 25px 16px;
}
.money {
font-size: 35px;
line-height: 48px;
}
.stat-top {
font-size: 12px;
}
.stat-bottom {
margin-top: 8px;
}
.card-heading {
padding: 25px 27px 0;
}
.card-heading h2 {
font-size: 15px;
}
.card-heading p {
font-size: 10px;
}
.chart-legend {
margin: 19px 27px 10px;
}
.chart-plot {
margin-top: 12px;
margin-bottom: 13px;
}
.chart-tabs button {
font-size: 10px;
padding: 7px 12px;
}
.donut-wrap {
width: 171px;
height: 171px;
margin: 14px auto 12px;
}
.breakdown-legend {
margin-left: 29px;
margin-right: 29px;
}
.breakdown-legend button {
font-size: 10px;
padding: 5px 0;
}
.breakdown-footer {
font-size: 9px;
}
.transaction-amount,
.operation-name strong {
font-size: 11px;
}
td {
padding-top: 13px;
padding-bottom: 13px;
}
}
@media (max-width: 1200px) {
:root {
--sidebar: 207px;
}
.sidebar {
padding-left: 13px;
padding-right: 13px;
}
.topbar {
padding: 0 25px;
}
.main-content {
padding: 26px 25px 0;
}
.topbar-right {
gap: 14px;
}
.breadcrumb {
gap: 10px;
font-size: 9px;
}
.topbar .today {
display: none;
}
.page-actions .button {
font-size: 10px;
padding: 10px 12px;
}
.page-heading p {
font-size: 10px;
}
h1 {
font-size: 28px;
}
.stat-card {
padding: 16px 17px 13px;
}
.stats-grid {
gap: 14px;
}
.money {
font-size: 28px;
}
.money-decimals,
.money-symbol {
font-size: 21px;
}
.charts-grid,
.insights-grid {
grid-template-columns: minmax(0, 1.85fr) minmax(253px, 1fr);
gap: 16px;
}
.card-heading {
padding-left: 18px;
padding-right: 18px;
}
.card-heading h2 {
font-size: 12px;
}
.card-heading p {
font-size: 8px;
}
.chart-tabs button {
padding: 5px 7px;
font-size: 8px;
}
.chart-tabs {
padding: 2px;
}
.title-dot {
display: none;
}
.chart-legend {
gap: 13px;
margin-left: 18px;
margin-right: 18px;
}
.legend-item {
font-size: 8px;
gap: 4px;
}
.legend-item b {
font-size: 9px;
}
.chart-footnote {
font-size: 8px;
margin-left: 18px;
margin-right: 18px;
}
.breakdown-legend {
margin-left: 20px;
margin-right: 20px;
}
.operation-name {
gap: 8px;
}
.category-icon {
width: 31px;
height: 31px;
border-radius: 8px;
}
td {
padding-left: 10px;
padding-right: 10px;
}
th {
padding-left: 10px;
padding-right: 10px;
}
th:first-child,
td:first-child {
padding-left: 18px;
}
.category-label {
padding: 4px 6px;
}
.categories-grid {
gap: 16px;
}
.category-card {
padding: 18px;
}
.categories-top p {
font-size: 8px;
}
}
@media (max-height: 800px) and (min-width: 901px) {
.sidebar {
padding-top: 23px;
}
.workspace-picker {
margin-top: 25px;
}
.calm-card {
padding-top: 13px;
}
.calm-flower {
width: 38px;
height: 38px;
margin-bottom: 5px;
}
.calm-card h3 {
font-size: 13px;
line-height: 21px;
}
.calm-card p {
margin-top: 5px;
}
.calm-footer {
margin-top: 12px;
padding: 9px 0;
}
.sidebar-bottom {
padding-top: 20px;
}
.profile {
padding-top: 16px;
padding-bottom: 16px;
}
}
@media (max-height: 695px) and (min-width: 901px) {
.calm-card {
display: none;
}
}
@media (max-width: 1050px) and (min-width: 901px) {
.chart-tabs {
gap: 0;
}
.chart-tabs button {
padding-left: 5px;
padding-right: 5px;
}
.flow-chart .card-heading {
gap: 7px;
flex-wrap: wrap;
}
.flow-chart .card-heading p {
display: none;
}
.chart-interval {
display: none;
}
.stat-top {
font-size: 10px;
}
.stat-icon {
width: 27px;
height: 27px;
}
.money {
font-size: 25px;
letter-spacing: -0.5px;
}
.money-decimals,
.money-symbol {
font-size: 19px;
}
.money-symbol {
margin-left: 5px;
}
.sparkline {
width: 65px;
}
.breakdown-card .card-heading h2 {
font-size: 11px;
}
.categories-grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
}
@media (max-width: 900px) {
:root {
--sidebar: 0px;
}
.sidebar {
width: 226px;
transform: translateX(-100%);
transition: transform 0.3s cubic-bezier(0.2, 0.8, 0.2, 1);
padding-left: 18px;
padding-right: 18px;
}
.sidebar.is-open {
transform: translateX(0);
z-index: 80;
}
.sidebar-backdrop {
display: block;
position: fixed;
inset: 0;
z-index: 75;
background: #3a2b4b44;
backdrop-filter: blur(3px);
animation: fade-in 0.2s;
}
.mobile-menu,
.mobile-close {
display: inline-flex;
}
.mobile-close {
width: 25px;
}
.sidebar .brand-button {
padding-left: 5px;
}
.topbar {
height: 65px;
}
.breadcrumb {
gap: 9px;
}
.breadcrumb > span {
font-size: 10px;
}
.topbar .today {
display: inline-flex;
}
.main-content {
padding: 25px 25px 0;
}
.chart-legend {
margin-top: 20px;
}
.categories-grid {
grid-template-columns: repeat(3, minmax(0, 1fr));
}
.calm-card {
max-height: 215px;
}
.calm-flower {
width: 42px;
height: 42px;
}
}
@media (max-width: 700px) {
.topbar {
padding: 0 19px;
}
.topbar-right {
gap: 13px;
}
.topbar .today,
.topbar-divider {
display: none;
}
.breadcrumb > span {
display: none;
}
.breadcrumb > svg {
display: none;
}
.breadcrumb strong {
font-size: 11px;
}
.global-search {
padding-left: 5px;
}
.global-search input {
width: 113px;
font-size: 10px;
}
.global-search kbd {
display: none;
}
.main-content {
padding: 23px 20px 0;
}
.page-heading {
align-items: flex-start;
margin-bottom: 22px;
}
h1 {
font-size: 27px;
line-height: 37px;
}
.page-heading p {
font-size: 10px;
line-height: 17px;
max-width: 260px;
}
.page-actions {
padding-top: 4px;
gap: 6px;
}
.page-actions .button.secondary {
padding: 10px;
font-size: 0;
gap: 0;
width: 37px;
}
.page-actions .button.primary {
padding: 10px;
font-size: 0;
gap: 0;
width: 39px;
border-radius: 9px;
}
.page-actions .button.primary svg {
width: 21px;
height: 21px;
}
.period-toolbar {
align-items: flex-start;
flex-wrap: wrap;
gap: 12px;
margin-bottom: 20px;
}
.period-controls button {
font-size: 10px;
padding-left: 13px;
padding-right: 13px;
}
.date-range {
align-self: center;
font-size: 9px;
gap: 5px;
}
.date-range svg {
width: 13px;
}
.stats-grid {
gap: 12px;
grid-template-columns: repeat(2, minmax(0, 1fr));
margin-bottom: 17px;
}
.balance-card {
grid-column: 1 / -1;
min-height: 123px;
}
.balance-card .stat-top {
justify-content: flex-start;
}
.balance-card .stat-icon {
position: absolute;
right: 3px;
top: 2px;
width: 34px;
height: 34px;
}
.balance-card .money {
font-size: 32px;
margin-top: 7px;
}
.balance-card .stat-bottom {
position: absolute;
bottom: 20px;
right: 19px;
}
.balance-art {
right: 22px;
top: -21px;
width: 230px;
height: 215px;
}
.stat-card {
padding: 17px 18px 12px;
}
.stat-top {
font-size: 11px;
}
.money {
font-size: 26px;
line-height: 37px;
}
.money-decimals,
.money-symbol {
font-size: 19px;
}
.stat-card:not(.balance-card) {
min-height: 138px;
}
.sparkline {
width: 72px;
}
.charts-grid,
.insights-grid {
grid-template-columns: 1fr;
gap: 17px;
margin-bottom: 17px;
}
.card-heading {
padding-left: 20px;
padding-right: 20px;
}
.card-heading h2 {
font-size: 13px;
}
.card-heading p {
font-size: 9px;
}
.chart-tabs button {
padding: 6px 9px;
font-size: 9px;
}
.chart-legend {
margin-left: 20px;
margin-right: 20px;
gap: 20px;
}
.legend-item {
font-size: 9px;
}
.legend-item b {
font-size: 10px;
}
.chart-interval {
font-size: 8px;
}
.chart-footnote {
font-size: 9px;
margin: 0 20px;
}
.breakdown-card {
display: grid;
grid-template-columns: 44% 56%;
grid-template-rows: auto 1fr auto;
}
.breakdown-card .card-heading {
grid-column: 1 / -1;
}
.donut-wrap {
grid-column: 1;
grid-row: 2;
align-self: center;
width: 159px;
height: 159px;
margin: 11px auto 12px;
}
.breakdown-legend {
grid-column: 2;
grid-row: 2;
align-self: center;
margin: 0 24px 0 8px;
gap: 10px;
}
.breakdown-legend button {
font-size: 10px;
}
.breakdown-footer {
grid-column: 1 / -1;
margin-left: 20px;
margin-right: 20px;
font-size: 9px;
}
.breakdown-type {
grid-column: 1 / -1;
grid-row: 2;
justify-self: center;
margin-bottom: 0;
}
.analytics-charts .donut-wrap {
grid-row: 3;
width: 169px;
height: 169px;
margin: 11px auto 12px;
}
.analytics-charts .breakdown-legend {
grid-row: 3;
}
.analytics-charts .breakdown-footer {
grid-row: 4;
}
.table-scroll {
overflow-x: auto;
min-height: 130px;
}
table {
min-width: 600px;
}
th:first-child,
td:first-child {
padding-left: 20px;
}
.transactions-card .card-heading {
padding-bottom: 17px;
}
.row-menu {
position: absolute;
}
.table-footer {
padding-left: 20px;
padding-right: 20px;
}
.operation-name strong {
font-size: 11px;
}
.transaction-amount {
font-size: 11px;
}
td {
padding-top: 12px;
padding-bottom: 12px;
}
.page-footer > span:first-child {
display: none;
}
.page-footer {
justify-content: center;
padding-top: 20px;
}
.page-footer > span:last-child {
flex-wrap: wrap;
justify-content: center;
font-size: 8px;
}
.transaction-filters {
flex-wrap: wrap;
}
.type-filter {
gap: 4px;
}
.type-filter button {
padding: 9px 11px;
font-size: 10px;
}
.filter-select {
padding: 9px 11px;
}
.filtered-summary {
flex-direction: column;
gap: 17px;
padding: 21px 2px;
}
.filtered-summary > div {
justify-content: space-between;
}
.filtered-summary b {
font-size: 12px;
}
.categories-grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 13px;
}
.categories-top {
margin-top: 20px;
flex-wrap: wrap;
gap: 12px;
}
.categories-top p {
font-size: 9px;
}
.category-card {
padding: 17px;
}
.category-card h2 {
font-size: 12px;
}
.category-total {
font-size: 21px;
}
.category-card .category-icon {
width: 38px;
height: 38px;
}
.new-category-card {
min-height: 237px;
}
.toast {
left: 50%;
bottom: 20px;
font-size: 10px;
width: max-content;
max-width: calc(100vw - 30px);
padding: 13px 12px 13px 15px;
}
.modal-overlay {
padding: 15px;
}
.modal {
max-height: calc(100dvh - 30px);
padding: 24px;
border-radius: 16px;
}
.modal-heading h2 {
font-size: 20px;
}
.modal-footer .button {
font-size: 10px;
padding-left: 13px;
padding-right: 13px;
}
.modal-heading p {
font-size: 10px;
}
}
@media (max-width: 430px) {
.topbar {
padding: 0 13px;
}
.top-avatar {
width: 26px;
height: 26px;
}
.topbar-right {
gap: 11px;
}
.main-content {
padding: 21px 16px 0;
}
h1 {
font-size: 26px;
}
.page-heading p {
max-width: 245px;
font-size: 9px;
}
.page-actions {
gap: 5px;
}
.page-actions .button.secondary {
display: none;
}
.page-actions .button.primary {
width: 37px;
min-height: 37px;
padding: 8px;
}
.period-toolbar {
gap: 12px;
}
.period-controls {
padding: 3px;
width: 100%;
justify-content: space-between;
}
.period-controls button {
flex: 1;
padding-left: 9px;
padding-right: 9px;
}
.date-range {
font-size: 9px;
}
.stat-card {
padding: 14px 15px 10px;
}
.balance-card {
min-height: 132px;
}
.balance-card .stat-bottom {
position: relative;
bottom: auto;
right: auto;
margin-top: 3px;
}
.balance-card .money {
font-size: 30px;
margin-top: 5px;
}
.balance-card .stat-icon {
top: 0;
right: 0;
}
.balance-art {
right: -15px;
top: -17px;
}
.stat-card:not(.balance-card) {
min-height: 132px;
}
.stat-top {
font-size: 10px;
}
.stat-icon {
width: 26px;
height: 26px;
}
.stat-icon svg {
width: 17px;
height: 17px;
}
.money {
font-size: 23px;
margin-top: 10px;
}
.money-decimals,
.money-symbol {
font-size: 17px;
}
.money-symbol {
margin-left: 5px;
}
.stat-meta {
font-size: 8px;
gap: 4px;
}
.sparkline {
width: 50px;
height: 29px;
margin-top: 0;
}
.card-heading {
padding-left: 16px;
padding-right: 16px;
gap: 7px;
}
.card-heading h2 {
font-size: 12px;
}
.card-heading p {
font-size: 8px;
}
.flow-chart .card-heading {
align-items: flex-start;
flex-wrap: wrap;
}
.chart-tabs {
padding: 2px;
}
.chart-tabs button {
padding: 5px 7px;
font-size: 8px;
}
.chart-legend {
margin: 16px 16px 7px;
gap: 15px;
}
.legend-item {
gap: 5px;
font-size: 8px;
}
.legend-item b {
font-size: 9px;
}
.chart-interval {
display: none;
}
.chart-footnote {
font-size: 8px;
margin-left: 16px;
margin-right: 16px;
gap: 3px;
}
.donut-wrap {
width: 145px;
height: 145px;
}
.donut-center > span {
font-size: 19px;
}
.donut-center small {
font-size: 7px;
}
.breakdown-legend {
margin-right: 16px;
margin-left: 9px;
gap: 8px;
}
.breakdown-legend button {
font-size: 8px;
}
.breakdown-legend button > span {
gap: 5px;
}
.breakdown-legend i {
width: 5px;
height: 5px;
}
.breakdown-footer {
margin-left: 16px;
margin-right: 16px;
font-size: 8px;
}
.text-button {
font-size: 9px;
gap: 6px;
}
.text-button svg {
width: 13px;
}
.count-badge {
font-size: 7px;
}
.table-footer {
padding-left: 16px;
padding-right: 16px;
font-size: 7px;
}
.saved-status {
gap: 4px;
}
.category-card {
padding: 14px;
}
.category-card h2 {
font-size: 10px;
}
.category-card-count {
font-size: 8px;
}
.category-total {
font-size: 18px;
padding-top: 18px;
}
.category-share {
font-size: 7px;
}
.new-category-card {
min-height: 219px;
}
.new-category-card strong {
font-size: 10px;
}
.new-category-card small {
font-size: 8px;
}
.analytics-charts .donut-wrap {
width: 145px;
height: 145px;
}
.comparison-head,
.comparison-row {
margin-left: 17px;
margin-right: 17px;
font-size: 9px;
}
.comparison-head {
font-size: 8px;
grid-template-columns: 1fr 0.7fr 1fr;
}
.comparison-row {
grid-template-columns: 1fr 0.7fr 1fr;
}
.comparison-row > span:first-child {
gap: 5px;
}
.comparison-row > span:first-child svg {
width: 14px;
}
.modal {
padding: 21px;
}
.modal-heading h2 {
font-size: 19px;
}
.modal-heading p {
font-size: 9px;
}
.form-row {
gap: 10px;
}
.field input,
.field select {
font-size: 10px;
padding-left: 10px;
padding-right: 10px;
}
.select-wrap select {
padding-right: 26px;
}
.select-wrap svg {
right: 10px;
}
.modal-footer .button {
padding-left: 12px;
padding-right: 12px;
font-size: 9px;
gap: 6px;
}
.color-options {
gap: 6px;
}
.color-options button {
width: 28px;
height: 28px;
}
.icon-options {
grid-template-columns: repeat(5, 1fr);
gap: 6px;
}
.privacy-box {
padding: 12px;
}
.settings-danger button {
font-size: 8px;
}
.help-item {
gap: 10px;
}
.help-item p {
font-size: 9px;
line-height: 19px;
}
.help-item h3 {
font-size: 11px;
}
.keyboard-hints {
gap: 16px;
}
}
@media (prefers-reduced-motion: reduce) {
*,
*::before,
*::after {
animation-duration: 0.01ms !important;
animation-delay: 0ms !important;
transition-duration: 0.01ms !important;
scroll-behavior: auto !important;
}
}
}
@layer components {
.app-shell {
overflow-x: clip;
}
.nav-item {
font-size: 13px;
}
.nav-label {
font-size: 9px;
}
.workspace-picker strong {
font-size: 12px;
}
.workspace-picker small {
font-size: 10px;
}
.workspace-picker {
gap: 8px;
}
.workspace-symbol {
width: 29px;
}
.calm-card p {
font-size: 11px;
color: #9586a8;
}
.calm-footer {
font-size: 10px;
}
.help-nav {
font-size: 11px;
}
.profile strong {
font-size: 11px;
}
.profile small {
font-size: 9px;
}
.breadcrumb,
.breadcrumb strong {
font-size: 11px;
}
.global-search input {
font-size: 11px;
width: 142px;
}
.today {
font-size: 11px;
}
.page-heading p {
font-size: 12px;
color: #96919f;
}
.button {
font-size: 12px;
}
.period-controls button {
font-size: 11px;
padding-top: 6px;
padding-bottom: 6px;
}
.period-controls button.active {
color: #675a79;
}
.date-range {
font-size: 11px;
}
.stat-top {
font-size: 12px;
color: #88808f;
}
.money {
font-size: 33px;
}
.stat-meta {
font-size: 10px;
color: #97909f;
}
.balance-status {
font-size: 10px;
color: #9480ab;
}
.card-heading h2 {
font-size: 15px;
color: #37323f;
}
.card-heading p {
font-size: 11px;
color: #a097a8;
}
.chart-tabs button {
font-size: 10px;
padding: 6px 10px;
}
.chart-tabs button.active {
color: #8063b2;
}
.chart-legend {
gap: 24px;
margin-top: 16px;
}
.legend-item {
font-size: 10px;
color: #968b9f;
}
.legend-item b {
font-size: 11px;
color: #6d5c7a;
}
.chart-interval {
font-size: 9px;
}
.axis-label {
font-size: 10px;
fill: #a49aae;
}
.chart-footnote {
font-size: 10px;
color: #9a8aa6;
min-height: 42px;
}
.chart-footnote strong {
color: #867294;
}
.chart-tooltip strong,
.chart-tooltip > div {
font-size: 10px;
}
.donut-wrap {
width: 153px;
height: 153px;
margin-top: 6px;
margin-bottom: 1px;
}
.donut-center > span {
color: #51485e;
font-size: 23px;
}
.donut-center small {
font-size: 9px;
color: #aa9bb7;
}
.breakdown-legend {
margin-bottom: 9px;
gap: 0;
}
.breakdown-legend button {
font-size: 11px;
padding: 4px 0;
line-height: 17px;
}
.breakdown-legend button > span {
color: #84748f;
}
.breakdown-legend button > b {
color: #756280;
}
.breakdown-legend button > b > span {
font-size: 10px;
}
.breakdown-footer {
font-size: 9px;
color: #ad9ebb;
padding: 11px 0;
}
.text-button {
font-size: 11px;
}
.count-badge {
font-size: 9px;
min-width: 20px;
height: 19px;
}
.table-scroll {
position: relative;
}
th {
font-size: 9px;
color: #a193ae;
}
.operation-name strong {
font-size: 12px;
color: #64566f;
font-weight: 600;
}
.operation-name small {
font-size: 10px;
color: #a697b2;
margin-top: 3px;
}
.category-label {
font-size: 10px;
color: #9580a6;
}
.date-cell {
font-size: 11px;
color: #9a87a8;
}
.transaction-amount {
font-size: 12px;
color: #766180;
}
.transaction-amount.income {
color: #659d82;
}
.category-icon {
width: 36px;
height: 36px;
}
.table-footer {
font-size: 9px;
}
.row-menu button {
font-size: 11px;
color: #8a6fa0;
}
.page-footer {
font-size: 9px;
}
.mobile-operation-date {
display: none;
}
.type-filter button {
font-size: 11px;
}
.filter-select select {
font-size: 11px;
}
.category-card h2 {
font-size: 14px;
color: #695375;
}
.category-total {
color: #735b84;
}
.category-card-count {
font-size: 11px;
color: #a793b6;
}
.category-share {
font-size: 10px;
}
.categories-top p {
font-size: 11px;
color: #a591b2;
}
.filtered-summary {
font-size: 11px;
}
.comparison-head {
font-size: 11px;
color: #a28aad;
}
.comparison-row {
font-size: 12px;
color: #9f86ac;
}
.comparison-row strong {
color: #785c8a;
}
.insight-card h2 {
color: #7e5f95;
}
.insight-line {
font-size: 11px;
}
.insight-line > span {
color: #a188b3;
}
.insight-line strong {
font-size: 12px;
color: #846199;
}
.insight-card p {
font-size: 10px;
color: #a18aac;
}
.breakdown-type button {
font-size: 10px;
}
.modal-heading h2 {
color: #45384f;
}
.modal-heading p {
font-size: 11px;
color: #9d89ab;
}
.type-switch button {
font-size: 12px;
color: #a089b1;
}
.amount-input > span {
font-size: 11px;
color: #9c81ae;
}
.field > span {
font-size: 12px;
color: #7c608f;
}
.field > span small {
font-size: 10px;
color: #ae96bd;
}
.field input,
.field select,
.field textarea {
font-size: 12px;
color: #775985;
}
.field input::placeholder,
.field textarea::placeholder {
font-size: 11px;
color: #baa5c7;
}
.field-hint,
.form-privacy {
font-size: 10px;
color: #ab92ba;
}
.modal-footer .button {
font-size: 11px;
}
.category-preview strong {
font-size: 14px;
color: #9371a6;
}
.privacy-box strong {
font-size: 12px;
color: #8d6aa2;
}
.privacy-box p {
font-size: 11px;
line-height: 19px;
color: #a68ab8;
}
.settings-section h3 {
font-size: 12px;
}
.data-actions > button > span {
font-size: 12px;
color: #9774ab;
}
.data-actions small {
font-size: 10px;
color: #b499c6;
}
.settings-danger button {
font-size: 10px;
}
.confirm-description {
font-size: 12px;
color: #967ca6;
}
.help-item h3 {
font-size: 13px;
color: #88629f;
}
.help-item p {
font-size: 12px;
color: #a589b9;
}
.keyboard-hints > span {
font-size: 10px;
}
}
@layer responsive {
@media (max-width: 1200px) and (min-width: 701px) {
.chart-tabs button {
font-size: 9px;
padding: 5px 7px;
}
.card-heading h2 {
font-size: 13px;
}
.card-heading p {
font-size: 10px;
}
.legend-item {
font-size: 9px;
}
.legend-item b {
font-size: 10px;
}
.chart-interval {
display: none;
}
.chart-footnote {
font-size: 9px;
}
.breakdown-legend button {
font-size: 10px;
}
.breakdown-legend button > b > span {
font-size: 9px;
}
.breakdown-card .card-heading h2 {
font-size: 13px;
}
.category-label {
font-size: 9px;
}
.date-cell,
.transaction-amount {
font-size: 10px;
}
.operation-name strong {
font-size: 11px;
}
.operation-name small {
font-size: 9px;
}
.workspace-picker strong {
font-size: 11px;
}
}
@media (max-width: 1050px) and (min-width: 901px) {
.card-heading h2 {
font-size: 12px;
}
.card-heading p {
font-size: 9px;
}
.breakdown-card .card-heading h2 {
font-size: 12px;
}
.money {
font-size: 27px;
}
.balance-status {
font-size: 8px;
}
.stat-meta {
font-size: 9px;
}
}
@media (max-width: 700px) {
.table-scroll {
overflow: visible;
}
table {
min-width: 0;
table-layout: fixed;
}
th:nth-child(2),
th:nth-child(3),
td:nth-child(2),
td:nth-child(3) {
display: none;
}
th:first-child,
td:first-child {
width: 53%;
padding-left: 20px;
padding-right: 7px;
}
th:nth-child(4),
td:nth-child(4) {
width: 37%;
padding-left: 4px;
padding-right: 7px;
}
.actions-head,
td:last-child {
width: 10%;
padding-right: 10px;
padding-left: 0;
}
.operation-name {
gap: 9px;
}
.operation-name strong {
font-size: 12px;
}
.operation-name small {
font-size: 9px;
line-height: 15px;
white-space: nowrap;
}
.mobile-operation-date {
display: inline;
}
.transaction-amount {
font-size: 11px;
}
th {
font-size: 9px;
padding-top: 12px;
padding-bottom: 12px;
}
.category-icon {
width: 34px;
height: 34px;
}
.row-actions .icon-button {
width: 25px;
}
.table-footer {
font-size: 9px;
}
.saved-status {
font-size: 8px;
}
.card-heading h2 {
font-size: 14px;
}
.card-heading p {
font-size: 10px;
}
.chart-tabs button {
font-size: 9px;
}
.chart-legend {
gap: 18px;
}
.legend-item {
font-size: 10px;
}
.legend-item b {
font-size: 11px;
}
.chart-footnote {
font-size: 9px;
}
.breakdown-legend button {
font-size: 11px;
}
.breakdown-footer {
font-size: 9px;
}
.donut-wrap {
margin-top: 13px;
margin-bottom: 13px;
}
.stat-meta {
font-size: 10px;
}
.balance-status {
font-size: 10px;
}
.page-heading p {
font-size: 11px;
}
.date-range {
font-size: 10px;
}
.type-filter button {
font-size: 11px;
}
.filter-select select {
font-size: 11px;
}
.category-card h2 {
font-size: 12px;
}
.category-card-count {
font-size: 10px;
}
.categories-top p {
font-size: 10px;
}
.category-share {
font-size: 9px;
}
.modal-heading p {
font-size: 11px;
}
.field input,
.field select,
.field textarea {
font-size: 13px;
}
.field > span {
font-size: 12px;
}
.help-item p {
font-size: 11px;
}
.settings-danger button {
font-size: 9px;
}
}
@media (max-width: 430px) {
.card-heading h2 {
font-size: 13px;
}
.card-heading p {
font-size: 9px;
}
.chart-tabs button {
font-size: 8px;
padding: 6px;
}
.flow-chart .card-heading {
flex-wrap: wrap;
gap: 12px;
}
.legend-item {
font-size: 9px;
}
.legend-item b {
font-size: 10px;
}
.chart-footnote {
font-size: 8px;
}
.breakdown-legend button {
font-size: 9px;
}
.breakdown-legend button > b > span {
font-size: 9px;
}
.donut-center > span {
font-size: 20px;
}
.donut-center small {
font-size: 8px;
}
.stat-meta {
font-size: 9px;
}
.balance-status {
font-size: 9px;
}
.text-button {
font-size: 9px;
}
.table-footer {
font-size: 8px;
padding-left: 16px;
padding-right: 16px;
}
.saved-status {
font-size: 7px;
}
th:first-child,
td:first-child {
padding-left: 14px;
}
.operation-name {
gap: 7px;
}
.operation-name strong {
font-size: 11px;
}
.operation-name small {
font-size: 8px;
}
.category-icon {
width: 29px;
height: 31px;
border-radius: 8px;
}
.transaction-amount {
font-size: 10px;
}
.page-heading p {
font-size: 10px;
}
.category-card h2 {
font-size: 11px;
}
.category-card-count {
font-size: 9px;
}
.category-share {
font-size: 8px;
}
.field input,
.field select {
font-size: 11px;
}
.modal-footer .button {
font-size: 10px;
}
}
}
@layer components {
.storage-warning.preview-note {
background: #f1ecfa;
border-color: #e3d8f4;
color: #9681af;
}
}
/* Дополнения для автономной версии на обычном JavaScript. */
[hidden] {
display: none !important;
}
.chart-plot {
min-width: 0;
}
.modal-content {
width: 100%;
}
.donut-segment:hover {
stroke-width: 25;
}
.toast-wrap:empty {
display: none;
}
.field-error {
color: #b55558;
}
.nav-item:not(.active) .nav-active-dot {
visibility: hidden;
}
input[type="search"]::-webkit-search-cancel-button {
display: none;
}
</style>
</head>
<body>
<div class="app-shell">
<div class="sidebar-backdrop" id="sidebar-backdrop" data-action="close-sidebar" hidden></div>
<!-- Боковое меню. На телефоне открывается кнопкой в верхней панели. -->
<aside class="sidebar" id="sidebar">
<div class="sidebar-top">
<button
class="brand-button"
data-action="navigate"
data-page="overview"
aria-label="Flow — на главную"
>
<span class="brand">
<span class="brand-icon">
<svg width="28" height="28" viewBox="0 0 40 40" fill="none" aria-hidden="true">
<path
d="M10 23c0-7 4.5-12 12-12h9M10 31c0-7 4.5-12 12-12h7"
stroke="currentColor"
stroke-width="4.6"
stroke-linecap="round"
></path>
</svg>
</span>
<span>flow<span class="brand-dot">.</span></span>
</span>
</button>
<button
class="icon-button mobile-close"
data-action="close-sidebar"
aria-label="Закрыть меню"
>
<span data-icon="x" data-size="20"></span>
</button>
</div>
<button class="workspace-picker" data-action="settings">
<span class="workspace-symbol"><span data-icon="wallet" data-size="17"></span></span>
<span
><strong id="workspace-name">Личные финансы</strong
><small>Ваше пространство</small></span
>
<span data-icon="chevron-down" data-size="13"></span>
</button>
<span class="nav-label">ВАШИ ФИНАНСЫ</span>
<nav class="main-nav" aria-label="Основная навигация">
<button
class="nav-item active"
data-action="navigate"
data-page="overview"
aria-current="page"
>
<span data-icon="dashboard" data-size="19"></span>
<span>Обзор</span><span class="nav-active-dot"></span>
</button>
<button class="nav-item" data-action="navigate" data-page="transactions">
<span data-icon="transactions" data-size="19"></span>
<span>Операции</span><span class="nav-count" id="transaction-count">0</span>
</button>
<button class="nav-item" data-action="navigate" data-page="analytics">
<span data-icon="chart" data-size="19"></span>
<span>Аналитика</span><span class="nav-active-dot"></span>
</button>
<button class="nav-item" data-action="navigate" data-page="categories">
<span data-icon="shapes" data-size="19"></span>
<span>Категории</span><span class="nav-active-dot"></span>
</button>
</nav>
<div class="sidebar-bottom">
<div class="calm-card">
<svg class="calm-flower" viewBox="0 0 66 66" aria-hidden="true">
<g fill="none" stroke="#927cdf" stroke-width="1.05">
<ellipse cx="33" cy="33" rx="12" ry="28"></ellipse>
<ellipse cx="33" cy="33" rx="12" ry="28" transform="rotate(60 33 33)"></ellipse>
<ellipse cx="33" cy="33" rx="12" ry="28" transform="rotate(120 33 33)"></ellipse>
<circle cx="33" cy="33" r="7"></circle>
</g>
</svg>
<span class="calm-spark">✦</span>
<h3>Больше ясности.<br />Меньше тревоги.</h3>
<p>Деньги любят порядок.<br />А вы — спокойствие.</p>
<span class="calm-footer"><span class="tiny-dot"></span>В своём ритме, с flow</span>
</div>
<button class="nav-item help-nav" data-action="help">
<span data-icon="help" data-size="18"></span>
<span>Помощь и подсказки</span>
<span data-icon="arrow-up-right" data-size="14"></span>
</button>
<div class="profile">
<span class="avatar">В</span>
<span
><strong>Ваш профиль</strong><small><i></i>Локальное хранение</small></span
>
<button class="icon-button tiny" data-action="settings" aria-label="Настройки">
<span data-icon="sliders" data-size="17"></span>
</button>
</div>
</div>
</aside>
<div class="main-shell">
<header class="topbar">
<div class="breadcrumb">
<button
class="icon-button mobile-menu"
data-action="open-sidebar"
aria-label="Открыть меню"
>
<span data-icon="menu" data-size="21"></span>
</button>
<span>Моё пространство</span>
<span data-icon="chevron-right" data-size="13"></span>
<strong id="breadcrumb-page">Обзор</strong>
</div>
<div class="topbar-right">
<label class="global-search" id="global-search-label">
<span data-icon="search" data-size="16"></span>
<input
id="global-search"
type="search"
placeholder="Поиск операций"
aria-label="Поиск операций"
autocomplete="off"
/>
<button
type="button"
id="clear-search"
data-action="clear-search"
aria-label="Очистить поиск"
hidden
>
<span data-icon="x" data-size="14"></span>
</button>
<kbd id="search-shortcut">⌘ K</kbd>
</label>
<span class="topbar-divider"></span>
<span class="today"
><span data-icon="calendar" data-size="15"></span><span id="today-label"></span
></span>
<button
class="top-avatar"
data-action="settings"
title="Настройки пространства"
aria-label="Настройки пространства"
>
В
</button>
</div>
</header>
<!-- Содержимое разделов строится функцией renderPage() из обычных HTML-шаблонов. -->
<main class="main-content" id="main-content"></main>
</div>
</div>
<!-- Окна и уведомления вынесены из основного содержимого для управления фокусом. -->
<div id="modal-root"></div>
<div id="toast-root" class="toast-wrap" aria-live="polite"></div>
<noscript>
<p style="padding: 24px">
Для работы Flow включите JavaScript в браузере. Сайт не отправляет ваши данные на сервер.
</p>
</noscript>
<script>
"use strict";
/*
* FLOW — автономный финансовый дашборд.
* Весь код открыт: обычный HTML, CSS и JavaScript без зависимостей.
* Для запуска сохраните файл с расширением .html и откройте в браузере.
* Данные остаются в localStorage; резервные копии — обычные JSON-файлы.
*/
// ---------------------------------------------------------------------------
// 1. Значки. Это обычные SVG-фигуры, а не изображения в Base64.
// ---------------------------------------------------------------------------
const ICONS = {
dashboard:
'<rect x="3" y="3" width="7" height="7" rx="1.5"/><rect x="14" y="3" width="7" height="7" rx="1.5"/><rect x="3" y="14" width="7" height="7" rx="1.5"/><rect x="14" y="14" width="7" height="7" rx="1.5"/>',
transactions: '<path d="M8 3 4 7l4 4M4 7h16M16 13l4 4-4 4M4 17h16"/>',
chart: '<path d="M3 3v18h18M7 15l4-5 4 2 6-7M7 18v-3M11 18v-4M15 18v-3M19 18v-7"/>',
shapes:
'<circle cx="17" cy="17" r="4"/><rect x="3" y="13" width="7" height="7" rx="1"/><path d="m7 3 4 7H3Z"/>',
search: '<circle cx="10.5" cy="10.5" r="7.5"/><path d="m16 16 5 5"/>',
"search-x":
'<circle cx="10.5" cy="10.5" r="7.5"/><path d="m16 16 5 5m-13-15 5 5m0-5-5 5"/>',
plus: '<path d="M12 5v14M5 12h14"/>',
x: '<path d="m6 6 12 12M18 6 6 18"/>',
"arrow-up-right": '<path d="M7 17 17 7M7 7h10v10"/>',
"arrow-down-left": '<path d="M17 7 7 17M7 7v10h10"/>',
"arrow-down-right": '<path d="m7 7 10 10M7 17h10V7"/>',
"arrow-right": '<path d="M4 12h16m-6-6 6 6-6 6"/>',
"chevron-left": '<path d="m15 18-6-6 6-6"/>',
"chevron-right": '<path d="m9 18 6-6-6-6"/>',
"chevron-down": '<path d="m6 9 6 6 6-6"/>',
download: '<path d="M12 3v12m-5-5 5 5 5-5M4 15v4a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2v-4"/>',
upload: '<path d="M12 16V4m-5 5 5-5 5 5M4 16v3a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2v-3"/>',
wallet:
'<path d="M20 8V5a2 2 0 0 0-2-2H6a3 3 0 0 0 0 6h14v11H6a3 3 0 0 1-3-3V6"/><path d="M20 12h-5v5h5M17 14.5h.01"/>',
sliders: '<path d="M4 7h6m4 0h6M4 17h10m4 0h2M10 4v6m4-6v6M14 14v6m4-6v6"/>',
more: '<circle cx="5" cy="12" r="1"/><circle cx="12" cy="12" r="1"/><circle cx="19" cy="12" r="1"/>',
pencil: '<path d="m15 5 4 4M4 20l5-1L21 7a2.8 2.8 0 0 0-4-4L5 15Z"/>',
trash: '<path d="M3 6h18M8 6V3h8v3M5 6l1 15h12l1-15M10 10v7M14 10v7"/>',
check: '<path d="m5 12 4 4L19 6"/>',
"double-check": '<path d="m2 12 4 4L16 6m-6 10 3 3L23 9"/>',
"check-circle": '<circle cx="12" cy="12" r="9"/><path d="m8 12 3 3 5-6"/>',
shield: '<path d="m12 3 8 3v6c0 5-8 9-8 9s-8-4-8-9V6Z"/><path d="m8 12 3 3 5-6"/>',
help: '<circle cx="12" cy="12" r="9"/><path d="M9.1 9a3 3 0 0 1 5.8 1c0 2-3 2-3 4M12 17h.01"/>',
alert: '<circle cx="12" cy="12" r="9"/><path d="M12 7v6M12 17h.01"/>',
menu: '<path d="M4 6h16M4 12h16M4 18h16"/>',
calendar:
'<rect x="3" y="5" width="18" height="16" rx="2"/><path d="M16 3v4M8 3v4M3 11h18M8 15h.01M12 15h.01M16 15h.01M8 18h.01M12 18h.01"/>',
server:
'<rect x="3" y="3" width="18" height="7" rx="2"/><rect x="3" y="14" width="18" height="7" rx="2"/><path d="M7 6.5h.01M7 17.5h.01M12 6.5h5M12 17.5h5"/>',
layers: '<path d="m12 3 10 5-10 5L2 8Zm-10 9 10 5 10-5M2 16l10 5 10-5"/>',
shopping:
'<path d="m5 3-2 5v12a1 1 0 0 0 1 1h16a1 1 0 0 0 1-1V8l-2-5ZM3 8h18M8 12a4 4 0 0 0 8 0"/>',
coffee:
'<path d="M4 8h13v9a4 4 0 0 1-4 4H8a4 4 0 0 1-4-4ZM17 9h2a3 3 0 0 1 0 6h-2M7 2v2M11 2v2M15 2v2"/>',
car: '<path d="m5 5-3 7v6h20v-6l-3-7ZM2 12h20M5 18v3M19 18v3M6 15h.01M18 15h.01"/>',
gift: '<rect x="3" y="8" width="18" height="4" rx="1"/><path d="M5 12v9h14v-9M12 8v13"/><path d="M12 8H7a3 3 0 1 1 3-3Zm0 0h5a3 3 0 1 0-3-3Z"/>',
briefcase:
'<rect x="3" y="7" width="18" height="14" rx="2"/><path d="M8 7V3h8v4M3 12a20 20 0 0 0 18 0M10 13v3h4v-3"/>',
other: '<circle cx="12" cy="12" r="9"/><path d="M7 12h.01M12 12h.01M17 12h.01"/>',
bank: '<path d="m3 7 9-5 9 5ZM4 21h16M6 10v8M10 10v8M14 10v8M18 10v8"/>',
sparkles:
'<path d="m12 3 2.5 6.5L21 12l-6.5 2.5L12 21l-2.5-6.5L3 12l6.5-2.5ZM20 2v4M18 4h4"/>',
lock: '<rect x="5" y="10" width="14" height="11" rx="2"/><path d="M8 10V6a4 4 0 0 1 8 0v4M12 14v3"/>',
sort: '<path d="M8 3v18m-4-4 4 4 4-4M16 21V3m-4 4 4-4 4 4"/>',
restore: '<path d="M3 11a9 9 0 1 1 2 7M3 4v7h7"/>',
database:
'<ellipse cx="12" cy="5" rx="9" ry="3"/><path d="M3 5v14c0 4 18 4 18 0V5M3 12c0 4 18 4 18 0"/>',
};
function icon(name, size = 18, className = "") {
const paths = ICONS[name] || ICONS.other;
return `<svg class="${className}" width="${size}" height="${size}" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">${paths}</svg>`;
}
// ---------------------------------------------------------------------------
// 2. Начальные данные и настройки.
// Эти четыре операции перенесены из исходной таблицы. Их можно удалить.
// ---------------------------------------------------------------------------
const DEFAULT_CATEGORIES = [
{
id: "infrastructure",
name: "Инфраструктура",
type: "expense",
color: "#8870dc",
icon: "server",
},
{ id: "services", name: "Сервисы и ПО", type: "expense", color: "#b6a2ea", icon: "layers" },
{ id: "shopping", name: "Покупки", type: "expense", color: "#edbea5", icon: "shopping" },
{ id: "food", name: "Еда и напитки", type: "expense", color: "#dfae71", icon: "coffee" },
{ id: "transport", name: "Транспорт", type: "expense", color: "#82a9c1", icon: "car" },
{ id: "other-expense", name: "Другое", type: "expense", color: "#a0a4b5", icon: "other" },
{ id: "salary", name: "Основной доход", type: "income", color: "#8a76df", icon: "wallet" },
{
id: "freelance",
name: "Подработка",
type: "income",
color: "#79b69d",
icon: "briefcase",
},
{ id: "gifts", name: "Подарки", type: "income", color: "#e2a3b6", icon: "gift" },
{ id: "other-income", name: "Другое", type: "income", color: "#a0a4b5", icon: "other" },
];
const DEMO_TRANSACTIONS = [
{
id: "sample-1",
name: "VPS2",
amount: 1800,
date: "2026-09-04",
type: "expense",
category: "infrastructure",
note: "",
createdAt: 1,
},
{
id: "sample-2",
name: "Voltx VPS2",
amount: 1100,
date: "2026-09-05",
type: "expense",
category: "services",
note: "",
createdAt: 2,
},
{
id: "sample-3",
name: "Cream",
amount: 315,
date: "2026-09-05",
type: "expense",
category: "shopping",
note: "",
createdAt: 3,
},
{
id: "sample-4",
name: "Доход",
amount: 232,
date: "2026-09-04",
type: "income",
category: "salary",
note: "",
createdAt: 4,
},
];
const DEFAULT_SETTINGS = { name: "Личные финансы", currency: "RUB" };
const STORAGE_KEYS = {
transactions: "flow.transactions.v1",
categories: "flow.categories.v1",
settings: "flow.settings.v1",
};
const CURRENCY_SYMBOLS = { RUB: "₽", EUR: "€", USD: "$" };
const PALETTE = [
"#8870dc",
"#b6a2ea",
"#edbea5",
"#79b69d",
"#82a9c1",
"#e2a3b6",
"#dfae71",
"#a0a4b5",
];
const CATEGORY_ICONS = [
"server",
"layers",
"shopping",
"coffee",
"car",
"gift",
"briefcase",
"other",
"wallet",
"bank",
];
const PERIODS = [
{ id: "3d", label: "3 дня", days: 3 },
{ id: "7d", label: "7 дней", days: 7 },
{ id: "month", label: "Месяц", days: 30 },
{ id: "all", label: "Всё время", days: null },
];
const PAGE_NAMES = {
overview: "Обзор",
transactions: "Операции",
analytics: "Аналитика",
categories: "Категории",
};
const HEADINGS = {
overview: ["Обзор финансов", "Всё, что важно. В одном месте."],
transactions: ["Все операции", "Ваша история доходов и расходов, без лишнего."],
analytics: ["Аналитика", "Замечайте закономерности. Принимайте лучшие решения."],
categories: ["Категории", "У каждой операции — своё место."],
};
const MONTHS = [
"янв.",
"февр.",
"мар.",
"апр.",
"мая",
"июн.",
"июл.",
"авг.",
"сент.",
"окт.",
"нояб.",
"дек.",
];
const ONE_DAY = 86400000;
const state = {
transactions: [],
categories: [],
settings: { ...DEFAULT_SETTINGS },
page: "overview",
period: "all",
search: "",
typeFilter: "all",
categoryFilter: "all",
categoryPageType: "expense",
breakdownType: "expense",
chartMode: "combined",
sortField: "date",
sortDirection: -1,
tablePage: 1,
storageError: false,
mobileOpen: false,
};
let chartModel = null;
let modalState = null;
let modalReturnFocus = null;
let toastTimer = null;
let resizeTimer = null;
const $ = (selector, root = document) => root.querySelector(selector);
const $$ = (selector, root = document) => [...root.querySelectorAll(selector)];
const clone = (value) => JSON.parse(JSON.stringify(value));
// Пользовательский текст экранируется перед вставкой в HTML.
function escapeHTML(value) {
return String(value ?? "").replace(
/[&<>"']/g,
(character) =>
({
"&": "&",
"<": "<",
">": ">",
'"': """,
"'": "'",
})[character],
);
}
function makeId() {
return (
globalThis.crypto?.randomUUID?.() ||
`id-${Date.now()}-${Math.random().toString(36).slice(2)}`
);
}
function toISO(date) {
return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, "0")}-${String(date.getDate()).padStart(2, "0")}`;
}
function todayISO() {
return toISO(new Date());
}
function parseDate(value) {
// Полдень в местном часовом поясе исключает сдвиг даты из-за UTC.
return new Date(`${value}T12:00:00`);
}
function addDays(date, count) {
const result = new Date(date);
result.setDate(result.getDate() + count);
return result;
}
function dayDifference(first, last) {
return Math.round((parseDate(last) - parseDate(first)) / ONE_DAY);
}
function isValidDate(value) {
return (
typeof value === "string" &&
/^\d{4}-\d{2}-\d{2}$/.test(value) &&
!Number.isNaN(parseDate(value).getTime()) &&
toISO(parseDate(value)) === value
);
}
function shortDate(value) {
const date = parseDate(value);
return `${date.getDate()} ${MONTHS[date.getMonth()]}`;
}
function longDate(value) {
return parseDate(value)
.toLocaleDateString("ru-RU", { day: "numeric", month: "long", year: "numeric" })
.replace(" г.", "");
}
function formatNumber(value, decimals = false) {
return new Intl.NumberFormat("ru-RU", {
minimumFractionDigits: decimals ? 2 : 0,
maximumFractionDigits: 2,
}).format(value);
}
function formatMoney(value, decimals = false) {
return `${value < 0 ? "−" : ""}${formatNumber(Math.abs(value), decimals)} ${CURRENCY_SYMBOLS[state.settings.currency]}`;
}
function moneyMarkup(value) {
const parts = Math.abs(value).toFixed(2).split(".");
return `<span class="money" title="${formatMoney(value, true)}">${value < 0 ? "−" : ""}${formatNumber(Number(parts[0]))}<span class="money-decimals">,${parts[1]}</span><span class="money-symbol">${CURRENCY_SYMBOLS[state.settings.currency]}</span></span>`;
}
function operationWord(count) {
const last = count % 10;
if (count % 100 >= 11 && count % 100 <= 14) return "операций";
return last === 1 ? "операция" : last >= 2 && last <= 4 ? "операции" : "операций";
}
// Суммируем целые копейки/центы, чтобы избежать ошибок вида 0.1 + 0.2.
function sumAmounts(items) {
return items.reduce((total, item) => total + Math.round(item.amount * 100), 0) / 100;
}
function getTotals(items) {
const income = sumAmounts(items.filter((item) => item.type === "income"));
const expense = sumAmounts(items.filter((item) => item.type === "expense"));
return { income, expense, balance: Math.round((income - expense) * 100) / 100 };
}
function findCategory(id, type = "expense") {
return (
state.categories.find((category) => category.id === id) ||
state.categories.find((category) => category.id === `other-${type}`) ||
DEFAULT_CATEGORIES.find((category) => category.id === `other-${type}`)
);
}
function categoryIcon(category, size = 18) {
return `<span class="category-icon" style="color:${category.color};background:${category.color}16">${icon(category.icon, size)}</span>`;
}
// ---------------------------------------------------------------------------
// 3. Загрузка и сохранение. Сервер и сетевые запросы не используются.
// ---------------------------------------------------------------------------
function validCategory(category) {
return (
category &&
typeof category.id === "string" &&
/^[a-zA-Z0-9_-]{1,80}$/.test(category.id) &&
typeof category.name === "string" &&
category.name.length > 0 &&
category.name.length <= 80 &&
["expense", "income"].includes(category.type) &&
/^#[0-9a-fA-F]{6}$/.test(category.color) &&
CATEGORY_ICONS.includes(category.icon)
);
}
function validTransaction(transaction) {
return (
transaction &&
typeof transaction.id === "string" &&
/^[a-zA-Z0-9_-]{1,80}$/.test(transaction.id) &&
typeof transaction.name === "string" &&
transaction.name.length > 0 &&
transaction.name.length <= 200 &&
typeof transaction.note === "string" &&
transaction.note.length <= 2000 &&
Number.isFinite(transaction.amount) &&
transaction.amount >= 0.01 &&
transaction.amount <= 999999999999 &&
Math.abs(transaction.amount - Math.round(transaction.amount * 100) / 100) < 0.00000001 &&
["expense", "income"].includes(transaction.type) &&
typeof transaction.category === "string" &&
Number.isFinite(transaction.createdAt) &&
isValidDate(transaction.date) &&
transaction.date >= "1900-01-01" &&
transaction.date <= todayISO()
);
}
function validSettings(settings) {
return (
settings &&
typeof settings.name === "string" &&
settings.name.length > 0 &&
Object.hasOwn(CURRENCY_SYMBOLS, settings.currency)
);
}
function readStorage(key, fallback, validate) {
try {
const raw = localStorage.getItem(key);
if (raw === null) return clone(fallback);
const value = JSON.parse(raw);
return validate(value) ? value : clone(fallback);
} catch {
state.storageError = true;
return clone(fallback);
}
}
function loadData() {
state.transactions = readStorage(
STORAGE_KEYS.transactions,
DEMO_TRANSACTIONS,
(items) => Array.isArray(items) && items.every(validTransaction),
);
state.categories = readStorage(
STORAGE_KEYS.categories,
DEFAULT_CATEGORIES,
(items) => Array.isArray(items) && items.every(validCategory),
);
state.settings = readStorage(STORAGE_KEYS.settings, DEFAULT_SETTINGS, validSettings);
ensureFallbackCategories();
saveData();
}
function ensureFallbackCategories() {
for (const category of DEFAULT_CATEGORIES.filter((item) => item.id.startsWith("other-"))) {
if (!state.categories.some((item) => item.id === category.id))
state.categories.push({ ...category });
}
state.transactions = state.transactions.map((item) => {
const category = state.categories.find(
(entry) => entry.id === item.category && entry.type === item.type,
);
return category ? item : { ...item, category: `other-${item.type}` };
});
}
function saveData() {
try {
localStorage.setItem(STORAGE_KEYS.transactions, JSON.stringify(state.transactions));
localStorage.setItem(STORAGE_KEYS.categories, JSON.stringify(state.categories));
localStorage.setItem(STORAGE_KEYS.settings, JSON.stringify(state.settings));
state.storageError = false;
} catch {
state.storageError = true;
}
}
// ---------------------------------------------------------------------------
// 4. Периоды, фильтры и данные диаграмм.
// ---------------------------------------------------------------------------
function getRange() {
const period = PERIODS.find((item) => item.id === state.period);
const end = todayISO();
const start = period.days
? toISO(addDays(parseDate(end), -(period.days - 1)))
: state.transactions.reduce(
(earliest, item) => (item.date < earliest ? item.date : earliest),
end,
);
return { start, end };
}
function rangeLabel(range) {
const first = parseDate(range.start);
const last = parseDate(range.end);
if (range.start === range.end) return longDate(range.end);
if (first.getFullYear() === last.getFullYear() && first.getMonth() === last.getMonth()) {
return `${first.getDate()} — ${longDate(range.end)}`;
}
const firstYear =
first.getFullYear() !== last.getFullYear() ? ` ${first.getFullYear()}` : "";
return `${shortDate(range.start)}${firstYear} — ${shortDate(range.end)} ${last.getFullYear()}`;
}
function periodTransactions(range = getRange()) {
return state.transactions.filter(
(item) => item.date >= range.start && item.date <= range.end,
);
}
function filteredTransactions() {
const query = state.search.trim().toLocaleLowerCase("ru-RU");
return periodTransactions().filter((item) => {
if (state.typeFilter !== "all" && item.type !== state.typeFilter) return false;
if (state.categoryFilter !== "all" && item.category !== state.categoryFilter)
return false;
const searchText =
`${item.name} ${item.note} ${item.amount} ${item.date} ${findCategory(item.category, item.type).name}`.toLocaleLowerCase(
"ru-RU",
);
return !query || searchText.includes(query);
});
}
function buildChartData(items, range) {
const days = dayDifference(range.start, range.end) + 1;
const interval = days <= 45 ? "day" : days <= 180 ? "week" : "month";
const buckets = new Map();
function bucketKey(date) {
if (interval === "month") return `${date.slice(0, 7)}-01`;
if (interval === "week")
return toISO(
addDays(parseDate(range.start), Math.floor(dayDifference(range.start, date) / 7) * 7),
);
return date;
}
if (interval === "month") {
const date = parseDate(range.start);
date.setDate(1);
while (toISO(date) <= range.end) {
const key = toISO(date);
buckets.set(key, { date: key, income: 0, expense: 0 });
date.setMonth(date.getMonth() + 1);
}
} else {
for (let index = 0; index < days; index += interval === "week" ? 7 : 1) {
const key = toISO(addDays(parseDate(range.start), index));
buckets.set(key, { date: key, income: 0, expense: 0 });
}
}
for (const item of items) {
const bucket = buckets.get(bucketKey(item.date));
if (bucket) bucket[item.type] += Math.round(item.amount * 100);
}
const points = [...buckets.values()].map((point) => ({
...point,
income: point.income / 100,
expense: point.expense / 100,
}));
return { points, interval };
}
// Монотонная кубическая интерполяция: плавные линии без отрицательных выбросов.
function smoothPath(points) {
if (points.length === 0) return "";
if (points.length === 1) return `M${points[0].x},${points[0].y}`;
const slopes = points
.slice(1)
.map((point, index) => (point.y - points[index].y) / (point.x - points[index].x));
const tangents = points.map((point, index) => {
if (index === 0) return slopes[0];
if (index === points.length - 1) return slopes.at(-1);
if (slopes[index - 1] * slopes[index] <= 0) return 0;
return (2 * slopes[index - 1] * slopes[index]) / (slopes[index - 1] + slopes[index]);
});
return points.reduce((path, point, index) => {
if (index === 0) return `M${point.x},${point.y}`;
const previous = points[index - 1];
const offset = (point.x - previous.x) / 3;
return `${path} C${previous.x + offset},${previous.y + tangents[index - 1] * offset} ${point.x - offset},${point.y - tangents[index] * offset} ${point.x},${point.y}`;
}, "");
}
function sparkline(values, color) {
const data = values.length > 1 ? values : [0, ...values];
const maximum = Math.max(1, ...data);
const points = data.map((value, index) => ({
x: 2 + (index / (data.length - 1)) * 84,
y: 31 - (value / maximum) * 24,
}));
return `<svg class="sparkline" width="90" height="38" viewBox="0 0 90 38" aria-hidden="true">
<path d="${smoothPath(points)}" fill="none" stroke="${color}" stroke-width="2" stroke-linecap="round"/>
<circle cx="${points.at(-1).x}" cy="${points.at(-1).y}" r="3" fill="${color}" stroke="white" stroke-width="2"/>
</svg>`;
}
// ---------------------------------------------------------------------------
// 5. HTML-шаблоны разделов. Разметка не минифицирована и доступна для правок.
// ---------------------------------------------------------------------------
function renderPageHeading() {
const [title, subtitle] = HEADINGS[state.page];
const categoryPage = state.page === "categories";
return `
<div class="page-heading">
<div>
<h1>${title}<span class="heading-dot">.</span></h1>
<p>${subtitle}</p>
</div>
<div class="page-actions">
<button class="button secondary" data-action="export" title="Скачать операции за выбранный период">
${icon("download", 16)}Экспорт
</button>
<button class="button primary" data-action="${categoryPage ? "new-category" : "new-transaction"}">
${icon("plus", 18)}${categoryPage ? "Новая категория" : "Добавить операцию"}
</button>
</div>
</div>
`;
}
function renderPeriodToolbar(range) {
return `
<div class="period-toolbar">
<div class="period-controls" role="group" aria-label="Период статистики">
${PERIODS.map(
(period) => `
<button data-action="period" data-period="${period.id}" class="${state.period === period.id ? "active" : ""}"
aria-pressed="${state.period === period.id}" ${period.id === "month" ? 'title="Последние 30 дней, включая сегодня"' : ""}>
${period.label}
</button>
`,
).join("")}
</div>
<div class="date-range">${icon("calendar", 14)}<span>${rangeLabel(range)}</span></div>
</div>
`;
}
function renderStats(items, chartData) {
const totals = getTotals(items);
const incomeCount = items.filter((item) => item.type === "income").length;
const expenseCount = items.length - incomeCount;
const balanceStatus =
totals.balance < 0
? "Расходы выше доходов"
: totals.balance > 0
? "Вы в плюсе"
: "Всё в равновесии";
return `
<div class="stats-grid">
<section class="stat-card balance-card">
<svg class="balance-art" width="190" height="180" viewBox="0 0 190 180" aria-hidden="true">
<circle cx="176" cy="89" r="83"/><circle cx="176" cy="89" r="62"/>
<circle cx="176" cy="89" r="41"/><circle cx="176" cy="89" r="20"/>
</svg>
<div class="stat-top">
<span>Баланс за период <span class="info-tip" title="Доходы минус расходы за выбранный период">${icon("help", 13)}</span></span>
<span class="stat-icon purple">${icon("wallet", 19)}</span>
</div>
${moneyMarkup(totals.balance)}
<div class="stat-bottom">
<span class="balance-status ${totals.balance < 0 ? "negative" : "positive"}">
${icon(totals.balance < 0 ? "arrow-down-right" : "arrow-up-right", 13)}${balanceStatus}
</span>
</div>
</section>
<section class="stat-card">
<div class="stat-top"><span>Доходы</span><span class="stat-icon green">${icon("arrow-down-left", 20)}</span></div>
${moneyMarkup(totals.income)}
<div class="stat-bottom">
<span class="stat-meta"><i class="tiny-dot green-dot"></i>${incomeCount} ${operationWord(incomeCount)}</span>
${sparkline(
chartData.points.map((point) => point.income),
"#8e79dc",
)}
</div>
</section>
<section class="stat-card">
<div class="stat-top"><span>Расходы</span><span class="stat-icon peach">${icon("arrow-up-right", 20)}</span></div>
${moneyMarkup(totals.expense)}
<div class="stat-bottom">
<span class="stat-meta"><i class="tiny-dot peach-dot"></i>${expenseCount} ${operationWord(expenseCount)}</span>
${sparkline(
chartData.points.map((point) => point.expense),
"#dfa588",
)}
</div>
</section>
</div>
`;
}
function renderFlowChart(items, range) {
const totals = getTotals(items);
const chartData = buildChartData(items, range);
const expanded = state.page === "analytics";
const modes = [
{ id: "combined", name: "Общая" },
{ id: "income", name: "Доходы" },
{ id: "expense", name: "Расходы" },
];
const intervalLabel = { day: "По дням", week: "По неделям", month: "По месяцам" }[
chartData.interval
];
let insight = "Добавьте первую операцию — здесь появится ваша история";
if (totals.balance < 0)
insight = `Расходы превышают доходы на <strong>${formatMoney(-totals.balance)}</strong>`;
if (totals.balance > 0)
insight = `За этот период вы в плюсе на <strong>${formatMoney(totals.balance)}</strong>`;
if (totals.balance === 0 && items.length) insight = "Доходы и расходы в равновесии";
return `
<section class="card flow-chart ${expanded ? "chart-expanded" : ""}">
<div class="card-heading">
<div><h2>Движение средств <span class="title-dot"></span></h2><p>Ваша финансовая картина за период</p></div>
<div class="chart-tabs" aria-label="Режим диаграммы">
${modes
.map(
(mode) => `
<button data-action="chart-mode" data-mode="${mode.id}" class="${state.chartMode === mode.id ? "active" : ""}" aria-pressed="${state.chartMode === mode.id}">${mode.name}</button>
`,
)
.join("")}
</div>
</div>
<div class="chart-legend">
${["income", "expense"]
.filter((type) => state.chartMode === "combined" || type === state.chartMode)
.map(
(type) => `
<span class="legend-item"><i style="background:${type === "income" ? "#8a73de" : "#e2a58a"}"></i>
${type === "income" ? "Доходы" : "Расходы"}<b>${formatMoney(totals[type])}</b>
</span>
`,
)
.join("")}
<span class="chart-interval">${intervalLabel}${icon("chart", 13)}</span>
</div>
<div class="chart-plot" id="chart-plot" tabindex="0" aria-label="Интерактивная диаграмма. Стрелки влево и вправо показывают суммы за день."></div>
<div class="chart-footnote"><span class="footnote-icon">${icon("sparkles", 13)}</span>${insight}</div>
</section>
`;
}
function renderBreakdown(items) {
const canSwitch = state.page === "analytics";
const type = canSwitch ? state.breakdownType : "expense";
const groups = state.categories
.filter((category) => category.type === type)
.map((category) => ({
...category,
amount: sumAmounts(
items.filter((item) => item.category === category.id && item.type === type),
),
}))
.filter((category) => category.amount > 0)
.sort((first, second) => second.amount - first.amount);
const total = sumAmounts(groups);
const shown =
groups.length > 4
? [
...groups.slice(0, 3),
{
id: "all",
name: "Остальные",
color: "#a8b7bf",
amount: sumAmounts(groups.slice(3)),
},
]
: groups;
const circumference = 2 * Math.PI * 64;
let accumulated = 0;
const segments = shown
.map((category) => {
const fraction = category.amount / total;
const length = Math.max(1, fraction * circumference - 5);
const offset = -accumulated;
accumulated += fraction * circumference;
return `
<circle class="donut-segment" cx="85" cy="85" r="64" fill="none" stroke="${category.color}" stroke-width="21"
stroke-dasharray="${length} ${circumference - length}" stroke-dashoffset="${offset}" transform="rotate(-90 85 85)"
data-action="filter-category" data-category="${escapeHTML(category.id)}" data-type="${type}">
<title>${escapeHTML(category.name)}: ${formatMoney(category.amount)} (${Math.round(fraction * 100)}%)</title>
</circle>
`;
})
.join("");
const categoryWord =
groups.length === 1
? "категория"
: groups.length >= 2 && groups.length <= 4
? "категории"
: "категорий";
return `
<section class="card breakdown-card">
<div class="card-heading">
<div><h2>Структура ${type === "expense" ? "расходов" : "доходов"}</h2><p>${type === "expense" ? "Всё по своим категориям" : "Откуда приходят деньги"}</p></div>
<button class="icon-button tiny" data-action="navigate" data-page="categories" aria-label="Перейти к категориям">${icon("arrow-up-right", 18)}</button>
</div>
${
canSwitch
? `
<div class="breakdown-type">
<button data-action="breakdown-type" data-type="expense" class="${type === "expense" ? "active" : ""}">Расходы</button>
<button data-action="breakdown-type" data-type="income" class="${type === "income" ? "active" : ""}">Доходы</button>
</div>
`
: ""
}
<div class="donut-wrap">
<svg class="donut" viewBox="0 0 170 170" role="img" aria-label="Распределение по категориям">
<circle cx="85" cy="85" r="64" fill="none" stroke="#f2eff8" stroke-width="21"/>
${segments}
</svg>
<div class="donut-center"><span>${formatMoney(total)}</span><small>всего ${type === "expense" ? "расходов" : "доходов"}</small></div>
</div>
<div class="breakdown-legend">
${
shown.length
? shown
.map(
(category) => `
<button data-action="filter-category" data-category="${escapeHTML(category.id)}" data-type="${type}"
title="${escapeHTML(category.name)}: ${formatMoney(category.amount)}">
<span><i style="background:${category.color}"></i>${escapeHTML(category.name)}</span>
<b>${Math.round((category.amount / total) * 100)}<span>%</span></b>
</button>
`,
)
.join("")
: '<p class="empty-donut-text">Здесь будут ваши категории,<br>когда появятся операции</p>'
}
</div>
<div class="breakdown-footer"><span>${groups.length} ${categoryWord} за период</span>${icon("shapes", 13)}</div>
</section>
`;
}
function renderTable(items, overview = false) {
const sorted = [...items].sort((first, second) => {
if (state.sortField === "amount")
return (first.amount - second.amount) * state.sortDirection;
return (
first[state.sortField].localeCompare(second[state.sortField], "ru") *
state.sortDirection || second.createdAt - first.createdAt
);
});
const pageSize = overview ? 4 : 10;
const totalPages = Math.max(1, Math.ceil(sorted.length / pageSize));
const currentPage = overview ? 1 : Math.min(state.tablePage, totalPages);
const start = (currentPage - 1) * pageSize;
const rows = sorted.slice(start, start + pageSize);
const shownLabel = overview
? rows.length
: `${start + 1}–${Math.min(start + pageSize, sorted.length)}`;
return `
<section class="card transactions-card">
<div class="card-heading">
<div>
<h2>${overview ? "Последние операции" : "История операций"}<span class="count-badge">${items.length}</span></h2>
<p>${overview ? "Маленькие детали. Полная картина." : `${items.length} ${operationWord(items.length)} по вашим условиям`}</p>
</div>
${overview ? `<button class="text-button" data-action="navigate" data-page="transactions">Все операции ${icon("arrow-right", 15)}</button>` : ""}
</div>
${
rows.length
? `
<div class="table-scroll">
<table>
<thead><tr>
<th><button data-action="sort" data-field="name">Операция ${icon("sort", 11)}</button></th>
<th>Категория</th>
<th><button data-action="sort" data-field="date">Дата ${icon("sort", 11)}</button></th>
<th class="amount-cell"><button data-action="sort" data-field="amount">Сумма ${icon("sort", 11)}</button></th>
<th class="actions-head"><span class="sr-only">Действия</span></th>
</tr></thead>
<tbody>
${rows
.map((item) => {
const category = findCategory(item.category, item.type);
return `
<tr>
<td>
<button class="operation-name" data-action="edit-transaction" data-id="${escapeHTML(item.id)}">
${categoryIcon(category)}
<span><strong>${escapeHTML(item.name)}</strong><small>${item.type === "income" ? "Поступление" : "Списание"}<span class="mobile-operation-date"> · ${shortDate(item.date)}</span></small></span>
</button>
</td>
<td><span class="category-label">${escapeHTML(category.name)}</span></td>
<td class="date-cell">${shortDate(item.date)} <span>${parseDate(item.date).getFullYear()}</span></td>
<td class="amount-cell transaction-amount ${item.type}">${item.type === "income" ? "+" : "−"}${formatMoney(item.amount, true)}</td>
<td><div class="row-actions">
<button class="icon-button tiny" data-action="row-menu" data-id="${escapeHTML(item.id)}" aria-label="Действия: ${escapeHTML(item.name)}" aria-expanded="false">${icon("more", 19)}</button>
</div></td>
</tr>
`;
})
.join("")}
</tbody>
</table>
</div>
<div class="table-footer">
<span>Показано ${shownLabel} из ${sorted.length}</span>
${
!overview && totalPages > 1
? `
<div class="pagination">
<button class="icon-button tiny" data-action="table-page" data-value="${currentPage - 1}" aria-label="Предыдущая страница" ${currentPage === 1 ? "disabled" : ""}>${icon("chevron-left", 16)}</button>
<span>${currentPage} / ${totalPages}</span>
<button class="icon-button tiny" data-action="table-page" data-value="${currentPage + 1}" aria-label="Следующая страница" ${currentPage === totalPages ? "disabled" : ""}>${icon("chevron-right", 16)}</button>
</div>
`
: `
<span class="saved-status ${state.storageError ? "danger-text" : ""}">
${icon(state.storageError ? "alert" : "double-check", 14)}${state.storageError ? "Сохранение недоступно" : "Сохранено на устройстве"}
</span>
`
}
</div>
`
: `
<div class="empty-state">
<span class="empty-icon">${icon("search-x", 28)}</span>
<h3>Пока ничего нет</h3><p>Добавьте операцию или выберите другой период.<br>Ваша финансовая история начинается здесь.</p>
<div>
<button class="button secondary small" data-action="reset-filters">${icon("restore", 14)}Сбросить фильтры</button>
<button class="button primary small" data-action="new-transaction">${icon("plus", 15)}Добавить операцию</button>
</div>
</div>
`
}
</section>
`;
}
function renderTransactionFilters() {
const types = [
{ id: "all", name: "Все операции" },
{ id: "income", name: "Доходы", icon: "arrow-down-left" },
{ id: "expense", name: "Расходы", icon: "arrow-up-right" },
];
const items = filteredTransactions();
const totals = getTotals(items);
return `
<div class="transaction-filters">
<div class="type-filter">
${types.map((type) => `<button data-action="type-filter" data-type="${type.id}" class="${state.typeFilter === type.id ? "active" : ""}">${type.icon ? icon(type.icon, 15) : ""}${type.name}</button>`).join("")}
</div>
<div class="filter-select">
${icon("shapes", 15)}
<select id="filter-category" aria-label="Фильтр по категории">
<option value="all">Все категории</option>
${state.categories
.filter((category) => state.typeFilter === "all" || category.type === state.typeFilter)
.map(
(category) => `
<option value="${escapeHTML(category.id)}" ${state.categoryFilter === category.id ? "selected" : ""}>
${escapeHTML(category.name)}${state.typeFilter === "all" && category.name === "Другое" ? ` (${category.type === "income" ? "доходы" : "расходы"})` : ""}
</option>
`,
)
.join("")}
</select>${icon("chevron-down", 13)}
</div>
</div>
${
state.search || state.typeFilter !== "all" || state.categoryFilter !== "all"
? `
<div class="active-filters">
<span>${state.search ? `Поиск: «${escapeHTML(state.search)}» · ` : ""}Найдено: ${items.length} ${operationWord(items.length)}</span>
<button data-action="reset-filters">${icon("x", 13)}Сбросить фильтры</button>
</div>
`
: ""
}
${renderTable(items)}
<div class="filtered-summary">
<span>Итого по выбранным операциям</span>
<div>
<span>Доходы <b class="income-text">+${formatMoney(totals.income)}</b></span>
<span>Расходы <b>${formatMoney(totals.expense)}</b></span>
<span>Баланс <b>${formatMoney(totals.balance)}</b></span>
</div>
</div>
`;
}
function renderInsights(items, range) {
const current = getTotals(items);
const days = dayDifference(range.start, range.end) + 1;
const previousEnd = toISO(addDays(parseDate(range.start), -1));
const previousStart = toISO(addDays(parseDate(range.start), -days));
const previous = getTotals(
state.transactions.filter(
(item) => item.date >= previousStart && item.date <= previousEnd,
),
);
const largest = items
.filter((item) => item.type === "expense")
.sort((first, second) => second.amount - first.amount)[0];
const allTime = state.period === "all";
return `
<div class="insights-grid">
<section class="card comparison-card">
<div class="card-heading"><div><h2>Период в цифрах</h2><p>${allTime ? "Главные показатели за всё время" : "Сравнение с предыдущим таким же периодом"}</p></div>${icon("chart", 20, "muted")}</div>
<div class="comparison-head"><span>Показатель</span><span>Сейчас</span><span>${allTime ? "В среднем за день" : "Ранее"}</span></div>
${[
{ key: "income", name: "Доходы", icon: "arrow-down-left" },
{ key: "expense", name: "Расходы", icon: "arrow-up-right" },
{ key: "balance", name: "Баланс", icon: "wallet" },
]
.map(
(row) => `
<div class="comparison-row"><span>${icon(row.icon, 16)}${row.name}</span><strong>${formatMoney(current[row.key])}</strong><span>${formatMoney(allTime ? current[row.key] / days : previous[row.key])}</span></div>
`,
)
.join("")}
</section>
<section class="card insight-card">
<div class="insight-symbol">${icon("sparkles", 21)}</div>
<h2>Чуть больше ясности</h2>
<div class="insight-line"><span>Средний расход в день</span><strong>${formatMoney(current.expense / days)}</strong></div>
<div class="insight-line"><span>Самый большой расход</span><strong>${largest ? `${formatMoney(largest.amount)}<small>${escapeHTML(largest.name)}</small>` : "—"}</strong></div>
<p><span class="tiny-dot green-dot"></span>${items.length} ${operationWord(items.length)} формируют вашу картину за период.</p>
</section>
</div>
`;
}
function renderCategories(items) {
const type = state.categoryPageType;
const total = getTotals(items)[type];
return `
<div class="categories-top">
<div class="type-filter">
<button data-action="category-type" data-type="expense" class="${type === "expense" ? "active" : ""}">${icon("arrow-up-right", 16)}Расходы</button>
<button data-action="category-type" data-type="income" class="${type === "income" ? "active" : ""}">${icon("arrow-down-left", 16)}Доходы</button>
</div>
<p>Нажмите на сумму, чтобы посмотреть операции</p>
</div>
<div class="categories-grid">
${state.categories
.filter((category) => category.type === type)
.map((category) => {
const entries = items.filter((item) => item.category === category.id);
const amount = sumAmounts(entries);
const percentage = total ? Math.round((amount / total) * 100) : 0;
return `
<section class="card category-card">
<div class="category-card-top">
${categoryIcon(category, 23)}
<button class="icon-button tiny" data-action="edit-category" data-id="${escapeHTML(category.id)}" aria-label="Настроить категорию ${escapeHTML(category.name)}">${icon("pencil", 15)}</button>
</div>
<h2>${escapeHTML(category.name)}</h2>
<span class="category-card-count">${entries.length} ${operationWord(entries.length)} за период</span>
<button class="category-total" data-action="filter-category" data-category="${escapeHTML(category.id)}" data-type="${type}">${formatMoney(amount)}${icon("arrow-up-right", 17)}</button>
<div class="category-progress"><span style="width:${percentage}%;background:${category.color}"></span></div>
<div class="category-share"><span>Доля ${type === "expense" ? "расходов" : "доходов"}</span><b>${percentage}%</b></div>
</section>
`;
})
.join("")}
<button class="new-category-card" data-action="new-category"><span>${icon("plus", 23)}</span><strong>Новая категория</strong><small>Немного больше порядка</small></button>
</div>
`;
}
function renderPage(animate = false) {
const range = getRange();
const items = periodTransactions(range);
const hasCharts = state.page === "overview" || state.page === "analytics";
let content = "";
if (hasCharts) {
const chartData = buildChartData(items, range);
content += renderStats(items, chartData);
content += `<div class="charts-grid ${state.page === "analytics" ? "analytics-charts" : ""}">${renderFlowChart(items, range)}${renderBreakdown(items)}</div>`;
}
if (state.page === "overview") content += renderTable(items, true);
if (state.page === "transactions") content += renderTransactionFilters();
if (state.page === "analytics") content += renderInsights(items, range);
if (state.page === "categories") content += renderCategories(items);
const preview = globalThis.origin === "null";
const storageWarning = state.storageError
? `
<div class="storage-warning ${preview ? "preview-note" : ""}">
${icon("alert", 17)}
${preview ? "В этом предпросмотре сохранение недоступно. Скачайте Flow.html и откройте его в браузере." : "Браузер не разрешил сохранить данные. Сделайте резервную копию перед закрытием страницы."}
</div>
`
: "";
$("#main-content").innerHTML = `
${storageWarning}
${renderPageHeading()}
${renderPeriodToolbar(range)}
${content}
<footer class="page-footer">
<span>Меньше шума. Больше смысла.</span>
<span>${icon("lock", 12)}Данные остаются на устройстве <i></i>flow © ${new Date().getFullYear()}</span>
</footer>
`;
$("#workspace-name").textContent = state.settings.name;
$("#transaction-count").textContent = state.transactions.length;
$("#breadcrumb-page").textContent = PAGE_NAMES[state.page];
$("#today-label").textContent = new Date().toLocaleDateString("ru-RU", {
day: "numeric",
month: "long",
});
document.title = `Flow — ${PAGE_NAMES[state.page]}`;
for (const button of $$(".main-nav [data-page]")) {
const active = button.dataset.page === state.page;
button.classList.toggle("active", active);
if (active) button.setAttribute("aria-current", "page");
else button.removeAttribute("aria-current");
}
syncSearchField();
if (hasCharts) paintChart();
else chartModel = null;
if (animate && !matchMedia("(prefers-reduced-motion: reduce)").matches) {
$("#main-content").animate(
[
{ opacity: 0.4, transform: "translateY(7px)" },
{ opacity: 1, transform: "translateY(0)" },
],
{ duration: 260, easing: "ease-out" },
);
}
}
function syncSearchField() {
const input = $("#global-search");
if (input.value !== state.search) input.value = state.search;
$("#global-search-label").classList.toggle("has-value", Boolean(state.search));
$("#clear-search").hidden = !state.search;
$("#search-shortcut").hidden = Boolean(state.search);
}
// ---------------------------------------------------------------------------
// 6. Отрисовка SVG-диаграммы и интерактивных подсказок.
// ---------------------------------------------------------------------------
function paintChart() {
const plot = $("#chart-plot");
if (!plot) return;
const range = getRange();
const data = buildChartData(periodTransactions(range), range);
const points =
data.points.length === 1 ? [data.points[0], { ...data.points[0] }] : data.points;
const keys = state.chartMode === "combined" ? ["expense", "income"] : [state.chartMode];
const colors = { income: "#8a73de", expense: "#e2a58a" };
const width = Math.max(270, plot.clientWidth);
const height = state.page === "analytics" ? 262 : 186;
const left = width < 400 ? 40 : 48;
const right = 16;
const top = 17;
const bottom = height - 30;
const largest = Math.max(0, ...points.flatMap((point) => keys.map((key) => point[key])));
const rawStep = (largest || 100) / 4;
const magnitude = 10 ** Math.floor(Math.log10(rawStep));
const factor = [1, 2, 2.5, 5, 10].find((value) => value * magnitude >= rawStep) || 10;
const step = Math.max(0.01, factor * magnitude);
const maximum = Math.max(step, Math.ceil((largest || 100) / step) * step);
const xAt = (index) =>
left + (index / Math.max(1, points.length - 1)) * (width - left - right);
const yAt = (value) => bottom - (value / maximum) * (bottom - top);
const labelEvery = Math.max(1, Math.ceil((points.length - 1) / (width < 450 ? 4 : 6)));
function axisLabel(value) {
if (value >= 1000000) return `${formatNumber(value / 1000000)} млн`;
if (value >= 10000) return `${formatNumber(value / 1000)} тыс.`;
return formatNumber(value);
}
const grid = Array.from({ length: Math.round(maximum / step) + 1 }, (_, index) => {
const value = index * step;
const y = yAt(value);
return `
<g><line x1="${left}" x2="${width - right}" y1="${y}" y2="${y}" stroke="#ececf2" stroke-dasharray="${index ? "3 5" : "none"}"/>
<text x="${left - 12}" y="${y + 4}" text-anchor="end" class="axis-label">${axisLabel(value)}</text></g>
`;
}).join("");
const lines = keys
.map((key) => {
const coordinates = points.map((point, index) => ({
x: xAt(index),
y: yAt(point[key]),
}));
const path = smoothPath(coordinates);
return `
<g>
<path class="chart-area" d="${path} L${xAt(points.length - 1)},${bottom} L${left},${bottom} Z" fill="url(#gradient-${key})"/>
<path class="chart-line" pathLength="1" d="${path}" fill="none" stroke="${colors[key]}" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"/>
<circle cx="${xAt(points.length - 1)}" cy="${yAt(points.at(-1)[key])}" r="3.8" fill="${colors[key]}" stroke="white" stroke-width="2"/>
</g>
`;
})
.join("");
const labels = points
.map((point, index) => {
if (data.points.length === 1 && index === 0) return "";
if (index % labelEvery && index !== points.length - 1) return "";
if (
index > 0 &&
index < points.length - 1 &&
index > points.length - 1 - labelEvery * 0.7
)
return "";
const label =
data.interval === "month"
? `${MONTHS[parseDate(point.date).getMonth()]} ${parseDate(point.date).getFullYear()}`
: shortDate(point.date);
const anchor = index === 0 ? "start" : index === points.length - 1 ? "end" : "middle";
return `<text x="${xAt(index)}" y="${height - 7}" text-anchor="${anchor}" class="axis-label">${label}</text>`;
})
.join("");
plot.innerHTML = `
<svg width="100%" height="${height}" viewBox="0 0 ${width} ${height}" role="img" aria-label="Движение средств за период">
<defs>
${["income", "expense"]
.map(
(key) => `
<linearGradient id="gradient-${key}" x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stop-color="${colors[key]}" stop-opacity="0.17"/>
<stop offset="100%" stop-color="${colors[key]}" stop-opacity="0.012"/>
</linearGradient>
`,
)
.join("")}
</defs>
${grid}<g class="chart-lines">${lines}</g>${labels}
<g id="chart-cursor"></g>
</svg>
<div class="chart-tooltip" id="chart-tooltip" hidden></div>
`;
chartModel = {
points,
keys,
colors,
interval: data.interval,
width,
height,
left,
right,
top,
bottom,
xAt,
yAt,
activeIndex: null,
};
plot.onpointermove = (event) => {
const x = event.clientX - plot.getBoundingClientRect().left;
const index = Math.round(((x - left) / (width - left - right)) * (points.length - 1));
showChartTooltip(Math.max(0, Math.min(points.length - 1, index)));
};
plot.onpointerleave = hideChartTooltip;
plot.onblur = hideChartTooltip;
plot.onkeydown = (event) => {
if (!["ArrowLeft", "ArrowRight"].includes(event.key)) return;
event.preventDefault();
const direction = event.key === "ArrowRight" ? 1 : -1;
const index = Math.max(
0,
Math.min(points.length - 1, (chartModel.activeIndex ?? -direction) + direction),
);
showChartTooltip(index);
};
}
function showChartTooltip(index) {
if (!chartModel || !$("#chart-tooltip")) return;
const model = chartModel;
const point = model.points[index];
model.activeIndex = index;
const x = model.xAt(index);
const tooltip = $("#chart-tooltip");
const periodName =
model.interval === "week" ? " · неделя" : model.interval === "month" ? " · месяц" : "";
tooltip.innerHTML = `
<strong>${longDate(point.date)}${periodName}</strong>
${[...model.keys]
.reverse()
.map(
(key) => `
<div><span><i style="background:${model.colors[key]}"></i>${key === "income" ? "Доходы" : "Расходы"}</span><b>${formatMoney(point[key])}</b></div>
`,
)
.join("")}
`;
tooltip.style.left = `${Math.max(5, Math.min(model.width - 210, x - 100))}px`;
tooltip.style.top = "0px";
tooltip.hidden = false;
$("#chart-cursor").innerHTML = `
<line x1="${x}" x2="${x}" y1="${model.top}" y2="${model.bottom}" stroke="#a5a0bd" stroke-dasharray="3 4"/>
${model.keys
.map(
(key) => `
<circle cx="${x}" cy="${model.yAt(point[key])}" r="8" fill="${model.colors[key]}" fill-opacity="0.14"/>
<circle cx="${x}" cy="${model.yAt(point[key])}" r="4" fill="${model.colors[key]}" stroke="white" stroke-width="2"/>
`,
)
.join("")}
`;
}
function hideChartTooltip() {
if ($("#chart-tooltip")) $("#chart-tooltip").hidden = true;
if ($("#chart-cursor")) $("#chart-cursor").innerHTML = "";
if (chartModel) chartModel.activeIndex = null;
}
// ---------------------------------------------------------------------------
// 7. Модальные окна. Один общий контейнер, управление фокусом и клавишей Esc.
// ---------------------------------------------------------------------------
function showModal(title, subtitle, content, options = {}) {
if (!modalState) modalReturnFocus = document.activeElement;
modalState = options;
$("#modal-root").innerHTML = `
<div class="modal-overlay">
<section class="modal ${options.className || ""}" role="dialog" aria-modal="true" aria-labelledby="modal-title">
<div class="modal-heading">
<div><h2 id="modal-title">${escapeHTML(title)}</h2>${subtitle ? `<p>${escapeHTML(subtitle)}</p>` : ""}</div>
<button class="icon-button modal-close" type="button" data-action="close-modal" aria-label="Закрыть">${icon("x", 20)}</button>
</div>
${content}
</section>
</div>
`;
document.body.style.overflow = "hidden";
requestAnimationFrame(() => {
const focusTarget =
$("[autofocus]", $("#modal-root")) ||
$("input:not([type=hidden]), select:not([disabled]), textarea", $("#modal-root")) ||
$("button", $("#modal-root"));
focusTarget?.focus();
});
}
function closeModal() {
if (!modalState) return;
$("#modal-root").innerHTML = "";
modalState = null;
document.body.style.overflow = state.mobileOpen ? "hidden" : "";
const focusTarget = modalReturnFocus;
requestAnimationFrame(() => {
if (focusTarget?.isConnected) focusTarget.focus();
});
}
function showFormError(message) {
const error = $("#form-error");
if (!error) return;
error.innerHTML = `${icon("alert", 16)}<span>${escapeHTML(message)}</span>`;
error.hidden = false;
}
function categoryOptions(type, selectedId) {
return state.categories
.filter((category) => category.type === type)
.map(
(category) =>
`<option value="${escapeHTML(category.id)}" ${selectedId === category.id ? "selected" : ""}>${escapeHTML(category.name)}</option>`,
)
.join("");
}
function openTransaction(id = null) {
const existing = id ? state.transactions.find((item) => item.id === id) : null;
const type = existing?.type || "expense";
const categoryId =
existing?.category || state.categories.find((category) => category.type === type)?.id;
showModal(
existing ? "Редактировать операцию" : "Новая операция",
existing
? "Все изменения сразу отразятся в статистике."
: "Ещё один шаг к ясности в финансах.",
`
<form id="transaction-form" class="transaction-form">
<input type="hidden" name="type" value="${type}">
<div class="type-switch">
<button type="button" data-action="transaction-type" data-type="expense" class="${type === "expense" ? "active expense" : ""}">${icon("arrow-up-right", 17)}Расход</button>
<button type="button" data-action="transaction-type" data-type="income" class="${type === "income" ? "active income" : ""}">${icon("arrow-down-left", 17)}Доход</button>
</div>
<label class="amount-input" for="transaction-amount">
<span>Сумма операции</span>
<div><input id="transaction-amount" name="amount" inputmode="decimal" placeholder="0,00" aria-label="Сумма операции"
value="${existing ? String(existing.amount).replace(".", ",") : ""}" maxlength="18" autocomplete="off" autofocus required>
<span>${CURRENCY_SYMBOLS[state.settings.currency]}</span>
</div>
</label>
<label class="field" for="transaction-name">
<span>Название <i>*</i></span>
<input id="transaction-name" name="name" value="${escapeHTML(existing?.name || "")}" placeholder="${type === "expense" ? "Например, оплата сервера" : "Например, оплата за проект"}" maxlength="80" required>
</label>
<div class="form-row">
<label class="field" for="transaction-category"><span>Категория</span>
<div class="select-wrap"><select id="transaction-category" name="category">${categoryOptions(type, categoryId)}</select>${icon("chevron-down", 14)}</div>
</label>
<label class="field" for="transaction-date"><span>Дата</span>
<input id="transaction-date" name="date" type="date" value="${existing?.date || todayISO()}" min="1900-01-01" max="${todayISO()}" required>
</label>
</div>
<label class="field" for="transaction-note"><span>Заметка <small>необязательно</small></span>
<textarea id="transaction-note" name="note" placeholder="То, что стоит запомнить…" maxlength="400" rows="2">${escapeHTML(existing?.note || "")}</textarea>
</label>
<div class="form-error" id="form-error" role="alert" hidden></div>
<div class="modal-footer">
<button class="button secondary" type="button" data-action="close-modal">Отмена</button>
<button class="button primary" type="submit">${icon("check", 17)}${existing ? "Сохранить изменения" : "Добавить операцию"}</button>
</div>
<p class="form-privacy">${icon("lock", 12)}Только на вашем устройстве. Только для вас.</p>
</form>
`,
{ kind: "transaction", editingId: existing?.id || null },
);
}
function changeTransactionType(type) {
const form = $("#transaction-form");
if (!form || !["income", "expense"].includes(type)) return;
form.elements.type.value = type;
for (const button of $$(".type-switch button", form)) {
const active = button.dataset.type === type;
button.className = active ? `active ${type}` : "";
}
$("#transaction-category").innerHTML = categoryOptions(type);
$("#transaction-name").placeholder =
type === "expense" ? "Например, оплата сервера" : "Например, оплата за проект";
$("#form-error").hidden = true;
}
function openCategory(id = null) {
const category = id ? state.categories.find((item) => item.id === id) : null;
const type = category?.type || state.categoryPageType;
const color = category?.color || PALETTE[0];
const selectedIcon = category?.icon || "other";
const canDelete = category && !category.id.startsWith("other-");
showModal(
category ? "Настроить категорию" : "Новая категория",
"Организуйте финансы так, как удобно вам.",
`
<form id="category-form" class="category-form">
<input type="hidden" name="color" value="${color}">
<input type="hidden" name="icon" value="${selectedIcon}">
<div class="category-preview">
<span id="category-preview-icon">${categoryIcon({ color, icon: selectedIcon }, 27)}</span>
<strong id="category-preview-name">${escapeHTML(category?.name || "Ваша категория")}</strong>
</div>
<label class="field" for="category-name"><span>Название</span>
<input id="category-name" name="name" value="${escapeHTML(category?.name || "")}" placeholder="Например, путешествия" maxlength="35" required autofocus>
</label>
<label class="field" for="category-type"><span>Тип категории</span>
<div class="select-wrap"><select id="category-type" name="type" ${category ? "disabled" : ""}>
<option value="expense" ${type === "expense" ? "selected" : ""}>Расходы</option>
<option value="income" ${type === "income" ? "selected" : ""}>Доходы</option>
</select>${icon("chevron-down", 14)}</div>
</label>
<div class="field"><span>Цвет</span><div class="color-options">
${PALETTE.map((value) => `<button type="button" data-action="pick-color" data-value="${value}" aria-label="Цвет ${value}" aria-pressed="${value === color}" style="background:${value}">${value === color ? icon("check", 18) : ""}</button>`).join("")}
</div></div>
<div class="field"><span>Значок</span><div class="icon-options">
${CATEGORY_ICONS.map((value) => `<button type="button" data-action="pick-icon" data-value="${value}" aria-label="Значок ${value}" aria-pressed="${value === selectedIcon}" class="${value === selectedIcon ? "active" : ""}">${icon(value, 20)}</button>`).join("")}
</div></div>
<div class="form-error" id="form-error" role="alert" hidden></div>
<div class="modal-footer">
${canDelete ? `<button class="button danger-ghost" type="button" data-action="delete-category" data-id="${escapeHTML(category.id)}">${icon("trash", 16)}Удалить</button>` : '<button class="button secondary" type="button" data-action="close-modal">Отмена</button>'}
<button class="button primary" type="submit">${icon("check", 16)}Сохранить</button>
</div>
</form>
`,
{ kind: "category", editingId: category?.id || null },
);
}
function updateCategoryPreview() {
const form = $("#category-form");
if (!form) return;
const color = form.elements.color.value;
const selectedIcon = form.elements.icon.value;
$("#category-preview-name").textContent =
form.elements.name.value.trim() || "Ваша категория";
$("#category-preview-icon").innerHTML = categoryIcon({ color, icon: selectedIcon }, 27);
for (const button of $$("[data-action=pick-color]", form)) {
const selected = button.dataset.value === color;
button.setAttribute("aria-pressed", String(selected));
button.innerHTML = selected ? icon("check", 18) : "";
}
for (const button of $$("[data-action=pick-icon]", form)) {
const selected = button.dataset.value === selectedIcon;
button.setAttribute("aria-pressed", String(selected));
button.classList.toggle("active", selected);
}
}
function openSettings() {
const currency = state.settings.currency;
showModal(
"Ваше пространство",
"Небольшие настройки для большого удобства.",
`
<form id="settings-form">
<div class="settings-fields">
<label class="field" for="settings-name"><span>Название пространства</span>
<input id="settings-name" name="name" value="${escapeHTML(state.settings.name)}" maxlength="32" required>
</label>
<label class="field" for="settings-currency"><span>Валюта</span>
<div class="select-wrap"><select id="settings-currency" name="currency" aria-label="Валюта">
<option value="RUB" ${currency === "RUB" ? "selected" : ""}>Российский рубль · ₽</option>
<option value="EUR" ${currency === "EUR" ? "selected" : ""}>Евро · €</option>
<option value="USD" ${currency === "USD" ? "selected" : ""}>Доллар США · $</option>
</select>${icon("chevron-down", 14)}</div>
<small class="field-hint">Меняется только символ. Суммы не конвертируются.</small>
</label>
</div>
<div class="privacy-box">${icon("shield", 22)}
<div><strong>Ваши данные принадлежат вам</strong><p>Хранятся в этом браузере, без отправки на сервер. Сделайте резервную копию перед очисткой браузера или сменой устройства.</p></div>
</div>
<div class="settings-section">
<h3>Управление данными <span>${state.transactions.length} ${operationWord(state.transactions.length)}</span></h3>
<div class="data-actions">
<button type="button" data-action="export-all">${icon("download", 16)}<span>Экспорт в CSV<small>Для Excel и Google Таблиц</small></span>${icon("chevron-right", 15)}</button>
<button type="button" data-action="backup">${icon("database", 16)}<span>Сохранить резервную копию<small>Операции, категории и настройки · JSON</small></span>${icon("chevron-right", 15)}</button>
<button type="button" data-action="choose-backup">${icon("upload", 16)}<span>Восстановить из копии<small>Заменить текущие данные файлом JSON</small></span>${icon("chevron-right", 15)}</button>
<input id="backup-file" type="file" accept="application/json,.json" hidden>
</div>
<div class="settings-danger">
<button type="button" data-action="restore-demo">${icon("restore", 13)}Вернуть пример</button>
<button type="button" class="danger-text" data-action="clear-data">${icon("trash", 13)}Удалить операции</button>
</div>
</div>
<div class="form-error" id="form-error" role="alert" hidden></div>
<div class="modal-footer"><button class="button secondary" type="button" data-action="close-modal">Отмена</button><button class="button primary" type="submit">${icon("check", 16)}Сохранить</button></div>
</form>
`,
{ kind: "settings", className: "settings-modal" },
);
}
function confirmAction(title, description, label, callback, dangerous = false) {
showModal(
title,
"",
`
<div class="confirm-symbol ${dangerous ? "danger" : ""}">${icon(dangerous ? "trash" : "restore", 24)}</div>
<p class="confirm-description">${escapeHTML(description)}</p>
<div class="modal-footer">
<button class="button secondary" data-action="close-modal">Отмена</button>
<button class="button ${dangerous ? "danger" : "primary"}" data-action="confirm">${escapeHTML(label)}</button>
</div>
`,
{ kind: "confirm", className: "confirm-modal", callback },
);
}
function openHelp() {
const tips = [
{
icon: "plus",
title: "Начните с одной операции",
text: "Добавляйте доходы и расходы, выбирайте дату и категорию. Изменить запись можно по нажатию на её название.",
},
{
icon: "calendar",
title: "Смотрите на картину целиком",
text: "Периоды «3 дня», «7 дней» и «Месяц» включают последние 3, 7 и 30 дней, включая сегодня. «Всё время» — вся ваша история.",
},
{
icon: "chart",
title: "Замечайте главное",
text: "Переключайте доходы и расходы на диаграмме. Наведите на график, чтобы увидеть точные суммы. Нажмите на категорию, чтобы посмотреть её операции.",
},
{
icon: "shield",
title: "Никаких лишних глаз",
text: "Данные сохраняются только в браузере на этом устройстве. В настройках можно скачать резервную копию и перенести её на другой компьютер.",
},
];
showModal(
"Знакомьтесь, flow",
"Простое пространство для ваших финансов.",
`
<div class="help-content">
${tips.map((tip) => `<div class="help-item"><span>${icon(tip.icon, 21)}</span><div><h3>${tip.title}</h3><p>${tip.text}</p></div></div>`).join("")}
<div class="keyboard-hints"><span><kbd>N</kbd>Новая операция</span><span><kbd>Ctrl / ⌘ K</kbd>Поиск</span></div>
<button class="button primary full-width" data-action="close-modal">Всё понятно ${icon("arrow-right", 16)}</button>
</div>
`,
{ kind: "help" },
);
}
// ---------------------------------------------------------------------------
// 8. Добавление, редактирование и удаление данных.
// ---------------------------------------------------------------------------
function submitTransaction(form) {
const input = Object.fromEntries(new FormData(form));
const normalizedAmount = String(input.amount).replace(/\s/g, "").replace(",", ".");
const amount = Number(normalizedAmount);
const name = String(input.name || "").trim();
if (!/^(?:\d+(?:\.\d{0,2})?|\.\d{1,2})$/.test(normalizedAmount)) {
return showFormError("Введите сумму с не более чем двумя знаками после запятой.");
}
if (!Number.isFinite(amount) || amount < 0.01 || amount > 999999999999) {
return showFormError("Сумма должна быть от 0,01 и меньше 1 триллиона.");
}
if (!name || name.length > 80)
return showFormError("Укажите название длиной до 80 символов.");
if (!isValidDate(input.date) || input.date < "1900-01-01" || input.date > todayISO()) {
return showFormError("Выберите корректную дату, не позднее сегодняшней.");
}
const category = state.categories.find(
(item) => item.id === input.category && item.type === input.type,
);
if (!category) return showFormError("Выберите категорию для этого типа операции.");
const existing = state.transactions.find((item) => item.id === modalState.editingId);
const transaction = {
id: existing?.id || makeId(),
name,
amount: Math.round(amount * 100) / 100,
date: input.date,
type: input.type,
category: input.category,
note: String(input.note || "")
.trim()
.slice(0, 400),
createdAt: existing?.createdAt || Date.now(),
};
const outsidePeriod = transaction.date < getRange().start;
if (existing) {
state.transactions = state.transactions.map((item) =>
item.id === transaction.id ? transaction : item,
);
} else {
state.transactions.push(transaction);
}
if (outsidePeriod) state.period = "all";
state.tablePage = 1;
saveData();
closeModal();
renderPage();
const message = existing
? "Изменения сохранены"
: transaction.type === "income"
? "Доход добавлен. Так держать!"
: "Расход добавлен. Всё под контролем.";
showToast(
state.storageError
? "Операция добавлена, но браузер не разрешил сохранить данные."
: outsidePeriod
? `${message} Выбран период «Всё время».`
: message,
state.storageError,
);
}
function deleteTransaction(id) {
const transaction = state.transactions.find((item) => item.id === id);
if (!transaction) return;
confirmAction(
"Удалить операцию?",
`«${transaction.name}» на сумму ${formatMoney(transaction.amount)} будет удалена из истории и статистики.`,
"Удалить операцию",
() => {
state.transactions = state.transactions.filter((item) => item.id !== id);
state.tablePage = 1;
saveData();
renderPage();
showToast("Операция удалена");
},
true,
);
}
function submitCategory(form) {
const input = Object.fromEntries(new FormData(form));
const existing = state.categories.find((item) => item.id === modalState.editingId);
const name = String(input.name || "").trim();
const type = existing?.type || input.type;
if (!name || name.length > 35)
return showFormError("Укажите название длиной до 35 символов.");
if (
state.categories.some(
(item) =>
item.id !== existing?.id &&
item.type === type &&
item.name.toLocaleLowerCase("ru-RU") === name.toLocaleLowerCase("ru-RU"),
)
) {
return showFormError("Категория с таким названием уже есть.");
}
const category = {
id: existing?.id || makeId(),
name,
type,
color: input.color,
icon: input.icon,
};
if (!validCategory(category)) return showFormError("Проверьте цвет и значок категории.");
state.categories = existing
? state.categories.map((item) => (item.id === existing.id ? category : item))
: [...state.categories, category];
state.categoryPageType = type;
saveData();
closeModal();
renderPage();
showToast("Категория сохранена");
}
function deleteCategory(id) {
const category = state.categories.find((item) => item.id === id);
if (!category || id.startsWith("other-")) return;
confirmAction(
"Удалить категорию?",
`Операции из категории «${category.name}» сохранятся и будут перенесены в «Другое».`,
"Удалить категорию",
() => {
state.transactions = state.transactions.map((item) =>
item.category === id ? { ...item, category: `other-${item.type}` } : item,
);
state.categories = state.categories.filter((item) => item.id !== id);
state.categoryFilter = "all";
saveData();
renderPage();
showToast("Категория удалена. Операции сохранены.");
},
true,
);
}
function submitSettings(form) {
const input = Object.fromEntries(new FormData(form));
const name = String(input.name || "").trim();
if (!name || name.length > 32)
return showFormError("Укажите название пространства длиной до 32 символов.");
if (!Object.hasOwn(CURRENCY_SYMBOLS, input.currency))
return showFormError("Выберите валюту из списка.");
state.settings = { name, currency: input.currency };
saveData();
closeModal();
renderPage();
showToast("Настройки сохранены");
}
// ---------------------------------------------------------------------------
// 9. Экспорт CSV и резервные копии в открытом формате JSON.
// ---------------------------------------------------------------------------
function downloadFile(content, mimeType, filename) {
const blob = new Blob([content], { type: mimeType });
const url = URL.createObjectURL(blob);
const link = document.createElement("a");
link.href = url;
link.download = filename;
document.body.append(link);
link.click();
link.remove();
setTimeout(() => URL.revokeObjectURL(url), 5000);
}
function exportCSV(items) {
if (!items.length) return showToast("В выбранном периоде нет операций для экспорта", true);
// Защищаем текстовые ячейки от интерпретации Excel как формул.
const safeCell = (text) => (/^[=+\-@\t\r]/.test(text) ? `'${text}` : text);
const quote = (value) => `"${String(value).replace(/"/g, '""')}"`;
const rows = [["Дата", "Тип", "Название", "Категория", "Сумма", "Валюта", "Заметка"]];
for (const item of [...items].sort((first, second) =>
second.date.localeCompare(first.date),
)) {
rows.push([
item.date,
item.type === "income" ? "Доход" : "Расход",
safeCell(item.name),
safeCell(findCategory(item.category, item.type).name),
String(item.amount).replace(".", ","),
state.settings.currency,
safeCell(item.note),
]);
}
// BOM помогает Excel правильно распознать UTF-8 и кириллицу.
const csv = "\uFEFF" + rows.map((row) => row.map(quote).join(";")).join("\r\n");
downloadFile(csv, "text/csv;charset=utf-8", `flow-операции-${todayISO()}.csv`);
showToast(`Экспортировано: ${items.length} ${operationWord(items.length)}`);
}
function saveBackup() {
const backup = {
version: 1,
exportedAt: new Date().toISOString(),
transactions: state.transactions,
categories: state.categories,
settings: state.settings,
};
downloadFile(
JSON.stringify(backup, null, 2),
"application/json;charset=utf-8",
`flow-backup-${todayISO()}.json`,
);
showToast("Резервная копия сохранена");
}
async function restoreBackup(file) {
try {
if (file.size > 10 * 1024 * 1024) throw new Error("Файл слишком большой");
const data = JSON.parse(await file.text());
if (
data.version !== 1 ||
!Array.isArray(data.transactions) ||
!Array.isArray(data.categories) ||
data.transactions.length > 100000 ||
data.categories.length > 500 ||
!validSettings(data.settings)
) {
throw new Error("Неверная структура копии");
}
if (!data.categories.every(validCategory) || !data.transactions.every(validTransaction))
throw new Error("Некорректные записи");
if (
new Set(data.categories.map((item) => item.id)).size !== data.categories.length ||
new Set(data.transactions.map((item) => item.id)).size !== data.transactions.length
)
throw new Error("Повторяющиеся идентификаторы");
if (
!data.transactions.every((item) =>
data.categories.some(
(category) => category.id === item.category && category.type === item.type,
),
)
) {
throw new Error("Не найдены категории операций");
}
// Берём только известные поля; лишние данные из файла не используются.
const transactions = data.transactions.map(
({ id, name, amount, date, type, category, note, createdAt }) => ({
id,
name,
amount,
date,
type,
category,
note,
createdAt,
}),
);
const categories = data.categories.map(({ id, name, type, color, icon }) => ({
id,
name,
type,
color,
icon,
}));
const settings = {
name: data.settings.name.slice(0, 32),
currency: data.settings.currency,
};
confirmAction(
"Восстановить данные?",
`В файле ${transactions.length} ${operationWord(transactions.length)}. Текущая история, категории и настройки будут заменены. При необходимости сначала сохраните их резервную копию.`,
"Восстановить",
() => {
state.transactions = transactions;
state.categories = categories;
state.settings = settings;
ensureFallbackCategories();
resetFilters(false);
state.period = "all";
saveData();
renderPage();
showToast("Ваши данные восстановлены");
},
);
} catch {
showToast("Не удалось открыть копию. Выберите корректный JSON-файл Flow.", true);
}
}
// ---------------------------------------------------------------------------
// 10. Навигация, меню и уведомления.
// ---------------------------------------------------------------------------
function navigate(page) {
if (!Object.hasOwn(PAGE_NAMES, page)) return;
state.page = page;
state.search = "";
state.typeFilter = "all";
state.categoryFilter = "all";
state.tablePage = 1;
toggleSidebar(false);
renderPage(true);
window.scrollTo({
top: 0,
behavior: matchMedia("(prefers-reduced-motion: reduce)").matches ? "instant" : "smooth",
});
}
function resetFilters(render = true) {
state.search = "";
state.typeFilter = "all";
state.categoryFilter = "all";
state.period = "all";
state.tablePage = 1;
if (render) renderPage();
}
function filterByCategory(category, type) {
state.page = "transactions";
state.search = "";
state.categoryFilter = category;
state.typeFilter = type;
state.tablePage = 1;
renderPage(true);
window.scrollTo({ top: 0, behavior: "smooth" });
}
function toggleSidebar(open) {
state.mobileOpen = open;
$("#sidebar").classList.toggle("is-open", open);
$("#sidebar-backdrop").hidden = !open;
document.body.style.overflow = open || modalState ? "hidden" : "";
}
function closeRowMenus() {
for (const menu of $$(".row-menu")) menu.remove();
for (const button of $$("[data-action=row-menu]")) {
button.classList.remove("selected");
button.setAttribute("aria-expanded", "false");
}
}
function toggleRowMenu(button) {
const wasOpen = button.getAttribute("aria-expanded") === "true";
closeRowMenus();
if (wasOpen) return;
const id = escapeHTML(button.dataset.id);
button.classList.add("selected");
button.setAttribute("aria-expanded", "true");
button.insertAdjacentHTML(
"afterend",
`
<div class="row-menu">
<button data-action="edit-transaction" data-id="${id}">${icon("pencil", 14)}Редактировать</button>
<button class="danger-text" data-action="delete-transaction" data-id="${id}">${icon("trash", 14)}Удалить</button>
</div>
`,
);
}
function showToast(message, error = false) {
clearTimeout(toastTimer);
$("#toast-root").innerHTML = `
<div class="toast ${error ? "error" : ""}" role="${error ? "alert" : "status"}">
${icon(error ? "alert" : "check-circle", 19)}
<span>${escapeHTML(message)}</span>
<button data-action="close-toast" aria-label="Закрыть уведомление">${icon("x", 15)}</button>
</div>
`;
toastTimer = setTimeout(() => {
$("#toast-root").innerHTML = "";
}, 4500);
}
// ---------------------------------------------------------------------------
// 11. Обработчики событий. Атрибуты data-action связывают кнопки с функциями.
// ---------------------------------------------------------------------------
function handleAction(button) {
const action = button.dataset.action;
switch (action) {
case "navigate":
navigate(button.dataset.page);
break;
case "open-sidebar":
toggleSidebar(true);
break;
case "close-sidebar":
toggleSidebar(false);
break;
case "new-transaction":
openTransaction();
break;
case "edit-transaction":
closeRowMenus();
openTransaction(button.dataset.id);
break;
case "delete-transaction":
closeRowMenus();
deleteTransaction(button.dataset.id);
break;
case "row-menu":
toggleRowMenu(button);
break;
case "transaction-type":
changeTransactionType(button.dataset.type);
break;
case "new-category":
openCategory();
break;
case "edit-category":
openCategory(button.dataset.id);
break;
case "delete-category":
deleteCategory(button.dataset.id);
break;
case "settings":
openSettings();
break;
case "help":
openHelp();
break;
case "close-modal":
closeModal();
break;
case "confirm": {
const callback = modalState?.callback;
closeModal();
if (callback) callback();
break;
}
case "close-toast":
clearTimeout(toastTimer);
$("#toast-root").innerHTML = "";
break;
case "clear-search":
state.search = "";
state.tablePage = 1;
renderPage();
$("#global-search").focus();
break;
case "reset-filters":
resetFilters();
break;
case "filter-category":
filterByCategory(button.dataset.category, button.dataset.type);
break;
case "period": {
if (!PERIODS.some((period) => period.id === button.dataset.period)) break;
state.period = button.dataset.period;
state.tablePage = 1;
renderPage();
$(`.period-controls [data-period="${state.period}"]`)?.focus({ preventScroll: true });
break;
}
case "chart-mode": {
state.chartMode = button.dataset.mode;
renderPage();
$(`.chart-tabs [data-mode="${state.chartMode}"]`)?.focus({ preventScroll: true });
break;
}
case "breakdown-type":
state.breakdownType = button.dataset.type;
renderPage();
break;
case "type-filter":
state.typeFilter = button.dataset.type;
state.categoryFilter = "all";
state.tablePage = 1;
renderPage();
break;
case "category-type":
state.categoryPageType = button.dataset.type;
renderPage();
break;
case "table-page":
state.tablePage = Math.max(1, Number(button.dataset.value));
renderPage();
break;
case "sort": {
const field = button.dataset.field;
if (!["name", "date", "amount"].includes(field)) break;
state.sortDirection = state.sortField === field ? -state.sortDirection : -1;
state.sortField = field;
renderPage();
break;
}
case "pick-color": {
if (!PALETTE.includes(button.dataset.value)) break;
$("#category-form").elements.color.value = button.dataset.value;
updateCategoryPreview();
break;
}
case "pick-icon": {
if (!CATEGORY_ICONS.includes(button.dataset.value)) break;
$("#category-form").elements.icon.value = button.dataset.value;
updateCategoryPreview();
break;
}
case "export":
exportCSV(
state.page === "transactions" ? filteredTransactions() : periodTransactions(),
);
break;
case "export-all":
exportCSV(state.transactions);
break;
case "backup":
saveBackup();
break;
case "choose-backup":
$("#backup-file")?.click();
break;
case "clear-data": {
confirmAction(
"Начать с чистого листа?",
"Все операции будут удалены. Категории и настройки останутся. Это действие нельзя отменить — рекомендуем сначала сохранить резервную копию.",
"Удалить все операции",
() => {
state.transactions = [];
resetFilters(false);
saveData();
renderPage();
showToast("История очищена. Можно начать заново.");
},
true,
);
break;
}
case "restore-demo": {
confirmAction(
"Вернуть пример из таблицы?",
"Текущие операции и категории будут заменены четырьмя записями с вашей картинки. Сначала сохраните резервную копию, если она нужна.",
"Вернуть пример",
() => {
state.transactions = clone(DEMO_TRANSACTIONS);
state.categories = clone(DEFAULT_CATEGORIES);
resetFilters(false);
saveData();
renderPage();
showToast("Пример восстановлен");
},
);
break;
}
}
}
document.addEventListener("click", (event) => {
if (!(event.target instanceof Element)) return;
if (!event.target.closest(".row-actions")) closeRowMenus();
const button = event.target.closest("[data-action]");
if (!button || button.disabled) return;
handleAction(button);
});
document.addEventListener("pointerdown", (event) => {
if (event.target instanceof Element && event.target.classList.contains("modal-overlay"))
closeModal();
});
document.addEventListener("submit", (event) => {
const form = event.target;
if (!(form instanceof HTMLFormElement)) return;
event.preventDefault();
if (form.id === "transaction-form") submitTransaction(form);
if (form.id === "category-form") submitCategory(form);
if (form.id === "settings-form") submitSettings(form);
});
document.addEventListener("input", (event) => {
if (event.target.id === "global-search") {
state.search = event.target.value;
state.page = "transactions";
state.period = "all";
state.typeFilter = "all";
state.categoryFilter = "all";
state.tablePage = 1;
renderPage();
}
if (event.target.id === "category-name") updateCategoryPreview();
});
document.addEventListener("change", (event) => {
if (event.target.id === "filter-category") {
state.categoryFilter = event.target.value;
state.tablePage = 1;
renderPage();
}
if (event.target.id === "backup-file" && event.target.files?.[0]) {
restoreBackup(event.target.files[0]);
event.target.value = "";
}
});
document.addEventListener("keydown", (event) => {
if (event.key === "Escape") {
closeModal();
closeRowMenus();
toggleSidebar(false);
return;
}
if (modalState && event.key === "Tab") {
const controls = $$(
"button:not([disabled]), input:not([type=hidden]):not([disabled]), select:not([disabled]), textarea, [tabindex='0']",
$("#modal-root"),
).filter((element) => element.offsetParent !== null);
if (!controls.length) return;
if (event.shiftKey && document.activeElement === controls[0]) {
event.preventDefault();
controls.at(-1).focus();
} else if (!event.shiftKey && document.activeElement === controls.at(-1)) {
event.preventDefault();
controls[0].focus();
}
return;
}
if ((event.ctrlKey || event.metaKey) && event.key.toLowerCase() === "k") {
event.preventDefault();
if (!modalState) $("#global-search").focus();
}
const editingText =
event.target instanceof Element &&
event.target.closest("input, textarea, select, [contenteditable='true']");
if (
event.key.toLowerCase() === "n" &&
!event.ctrlKey &&
!event.metaKey &&
!event.altKey &&
!editingText &&
!modalState
) {
event.preventDefault();
openTransaction();
}
});
window.addEventListener("resize", () => {
clearTimeout(resizeTimer);
resizeTimer = setTimeout(() => {
if (window.innerWidth > 900 && state.mobileOpen) toggleSidebar(false);
paintChart();
}, 100);
});
// ---------------------------------------------------------------------------
// 12. Запуск приложения. Ничего загружать с сервера не требуется.
// ---------------------------------------------------------------------------
for (const placeholder of $$("[data-icon]")) {
placeholder.outerHTML = icon(
placeholder.dataset.icon,
Number(placeholder.dataset.size) || 18,
);
}
loadData();
renderPage();
</script>
<script>(function(){function c(){var b=a.contentDocument||(a.contentWindow&&a.contentWindow.document);if(b){var d=b.createElement('script');d.innerHTML="window.__CF$cv$params={r:'a38c1efb19cf0d2b',t:'MTc4OTAxOTA2OA=='};var a=document.createElement('script');a.src='/cdn-cgi/challenge-platform/scripts/jsd/main.js';document.getElementsByTagName('head')[0].appendChild(a);";b.getElementsByTagName('head')[0].appendChild(d)}}if(document.body){var a=document.createElement('iframe');a.height=1;a.width=1;a.style.position='absolute';a.style.top=0;a.style.left=0;a.style.border='none';a.style.visibility='hidden';document.body.appendChild(a);if('loading'!==document.readyState)c();else if(window.addEventListener)document.addEventListener('DOMContentLoaded',c);else{var e=document.onreadystatechange||function(){};document.onreadystatechange=function(b){e(b);'loading'!==document.readyState&&(document.onreadystatechange=e,c())}}}})();</script></body>
</html>