updateChartSlot(0, chart.id)} size="sm" variant="ghost">
Slot 1
From 82aa267e417f40ad62d4bdc9b78eed8d63ebd81f Mon Sep 17 00:00:00 2001
From: Ermolz <00ermzahar@gmail.com>
Date: Wed, 24 Jun 2026 18:22:15 +0300
Subject: [PATCH 3/5] Add analyticsActiveCurrency to LocalStorageKey
---
app/src/shared/lib/storage/storage-keys.ts | 2 ++
1 file changed, 2 insertions(+)
diff --git a/app/src/shared/lib/storage/storage-keys.ts b/app/src/shared/lib/storage/storage-keys.ts
index 05cbc3b..476fdd3 100644
--- a/app/src/shared/lib/storage/storage-keys.ts
+++ b/app/src/shared/lib/storage/storage-keys.ts
@@ -16,6 +16,7 @@ export type LocalStorageKey =
| 'templateReadyAt'
| 'templateReadySpreadsheetId'
| 'templateReadyVersion'
+ | 'analyticsActiveCurrency'
| 'analyticsChartSlots'
| 'analyticsWideChartId'
| 'googleAuthEverConnected';
@@ -38,6 +39,7 @@ export const localStorageKeys: LocalStorageKey[] = [
'templateReadyAt',
'templateReadySpreadsheetId',
'templateReadyVersion',
+ 'analyticsActiveCurrency',
'analyticsChartSlots',
'analyticsWideChartId',
'googleAuthEverConnected',
From 70941145c8ffaabdd95810321f3e147a625fbf24 Mon Sep 17 00:00:00 2001
From: Ermolz <00ermzahar@gmail.com>
Date: Wed, 24 Jun 2026 19:00:04 +0300
Subject: [PATCH 4/5] Fix empty-state crash and replace native date input with
custom DatePicker
## Bug fix: undefined valueRange.values crash
Google Sheets API omits the values field entirely when a range is empty,
causing a crash (Cannot read properties of undefined reading map) in every
service that called .map() directly on the response.
Fixed with a ?? [] fallback in all affected services:
- manage-transactions.service.ts (3 call sites: getTransactions,
findTransactionRowIndex, softDeleteTransaction)
- manage-categories.service.ts
- manage-containers.service.ts
- manage-currencies.service.ts
- analytics-stats.service.ts (monthlyStats + categoryStats)
## Empty state: added Refresh button
When the transactions list is empty, EmptyState now shows two buttons:
Create transaction (primary) and Refresh (secondary). EmptyState component
extended with optional secondActionLabel / onSecondAction props.
## New shared DatePicker component
Replaced native
(browser-rendered, unstyled) with a
fully custom DatePicker in shared/ui/date-picker/:
- Trigger button styled consistently with Input (h-10, brand focus ring)
- Calendar icon (SVG) instead of the raw glyph
- SVG chevrons for month navigation
- Today highlighted with a brand-color border (no fill)
- Selected day has brand-color background
- Closes on Escape key and outside pointer click
- Supports label, error, hint props matching the Input API
Replaced usages:
- CreateTransactionModal: native Input type=date replaced with DatePicker
- TransactionsHistory: local DateFilterField component removed entirely,
both filter date fields now use the shared DatePicker
---
.../lib/analytics-stats.service.ts | 8 +-
.../ui/CreateTransactionModal.tsx | 6 +-
.../lib/manage-categories.service.ts | 2 +-
.../lib/manage-containers.service.ts | 2 +-
.../lib/manage-currencies.service.ts | 2 +-
.../lib/manage-transactions.service.ts | 9 +-
app/src/shared/ui/date-picker/DatePicker.tsx | 261 ++++++++++++++++++
app/src/shared/ui/date-picker/index.ts | 1 +
app/src/shared/ui/empty-state/EmptyState.tsx | 15 +-
.../ui/TransactionsHistory.tsx | 154 +----------
10 files changed, 296 insertions(+), 164 deletions(-)
create mode 100644 app/src/shared/ui/date-picker/DatePicker.tsx
create mode 100644 app/src/shared/ui/date-picker/index.ts
diff --git a/app/src/features/analyze-transactions/lib/analytics-stats.service.ts b/app/src/features/analyze-transactions/lib/analytics-stats.service.ts
index cf5bed1..b6a1851 100644
--- a/app/src/features/analyze-transactions/lib/analytics-stats.service.ts
+++ b/app/src/features/analyze-transactions/lib/analytics-stats.service.ts
@@ -58,8 +58,12 @@ export async function readAnalyticsStats({
spreadsheetId,
}),
]);
- const monthlyStats = monthlyRange.values.map(mapRowToMonthlyStats).filter(isMonthlyStats);
- const categoryStats = categoryRange.values.map(mapRowToCategoryStats).filter(isCategoryStats);
+ const monthlyStats = (monthlyRange.values ?? [])
+ .map(mapRowToMonthlyStats)
+ .filter(isMonthlyStats);
+ const categoryStats = (categoryRange.values ?? [])
+ .map(mapRowToCategoryStats)
+ .filter(isCategoryStats);
if (!monthlyStats.length && !categoryStats.length) {
return localStats;
diff --git a/app/src/features/create-transaction/ui/CreateTransactionModal.tsx b/app/src/features/create-transaction/ui/CreateTransactionModal.tsx
index 361e5be..76401b4 100644
--- a/app/src/features/create-transaction/ui/CreateTransactionModal.tsx
+++ b/app/src/features/create-transaction/ui/CreateTransactionModal.tsx
@@ -7,6 +7,7 @@ import { CategoryCombobox } from '@features/manage-categories';
import { ContainerCombobox } from '@features/manage-containers';
import { CurrencySelect } from '@features/manage-currencies';
import { Button } from '@shared/ui/button';
+import { DatePicker } from '@shared/ui/date-picker';
import { Input } from '@shared/ui/input';
import { localStorageService } from '@shared/lib/storage/local-storage.service';
import { Modal } from '@shared/ui/modal';
@@ -243,11 +244,10 @@ export function CreateTransactionModal({
Income
-
setValues({ ...values, date: event.target.value })}
- type="date"
+ onChange={(date) => setValues({ ...values, date })}
value={values.date}
/>
category !== null);
diff --git a/app/src/features/manage-containers/lib/manage-containers.service.ts b/app/src/features/manage-containers/lib/manage-containers.service.ts
index 3047a51..b783a71 100644
--- a/app/src/features/manage-containers/lib/manage-containers.service.ts
+++ b/app/src/features/manage-containers/lib/manage-containers.service.ts
@@ -77,7 +77,7 @@ export async function readContainers({
range: sheetRanges.containersData,
spreadsheetId,
});
- const remoteContainers = valueRange.values
+ const remoteContainers = (valueRange.values ?? [])
.map(mapRowToContainer)
.filter((container) => container !== null);
diff --git a/app/src/features/manage-currencies/lib/manage-currencies.service.ts b/app/src/features/manage-currencies/lib/manage-currencies.service.ts
index 8ab2829..d899a9a 100644
--- a/app/src/features/manage-currencies/lib/manage-currencies.service.ts
+++ b/app/src/features/manage-currencies/lib/manage-currencies.service.ts
@@ -65,7 +65,7 @@ export async function readCurrencies({
range: sheetRanges.currenciesData,
spreadsheetId,
});
- const remoteCurrencies = valueRange.values
+ const remoteCurrencies = (valueRange.values ?? [])
.map(mapRowToCurrency)
.filter((currency): currency is Currency => currency !== null);
diff --git a/app/src/features/manage-transactions/lib/manage-transactions.service.ts b/app/src/features/manage-transactions/lib/manage-transactions.service.ts
index ad6322b..4e43a64 100644
--- a/app/src/features/manage-transactions/lib/manage-transactions.service.ts
+++ b/app/src/features/manage-transactions/lib/manage-transactions.service.ts
@@ -97,7 +97,7 @@ export async function readTransactions({
spreadsheetId,
});
- return valueRange.values
+ return (valueRange.values ?? [])
.map(mapRowToTransaction)
.filter((transaction) => transaction !== null)
.sort((left, right) => right.createdAt.localeCompare(left.createdAt));
@@ -155,7 +155,7 @@ async function findTransactionRowIndex({
range: sheetRanges.ledgerData,
spreadsheetId,
});
- const rowIndex = valueRange.values.findIndex((row) => row[0] === transactionId);
+ const rowIndex = (valueRange.values ?? []).findIndex((row) => row[0] === transactionId);
return rowIndex === -1 ? null : rowIndex + 2;
}
@@ -231,13 +231,14 @@ export async function softDeleteTransaction({
range: sheetRanges.ledgerData,
spreadsheetId,
});
- const rowIndex = valueRange.values.findIndex((row) => row[0] === transactionId);
+ const rows = valueRange.values ?? [];
+ const rowIndex = rows.findIndex((row) => row[0] === transactionId);
if (rowIndex === -1) {
throw new Error('Transaction was not found in Ledger.');
}
- const transaction = mapRowToTransaction(valueRange.values[rowIndex]);
+ const transaction = mapRowToTransaction(rows[rowIndex]);
if (!transaction) {
throw new Error('Transaction row is invalid.');
diff --git a/app/src/shared/ui/date-picker/DatePicker.tsx b/app/src/shared/ui/date-picker/DatePicker.tsx
new file mode 100644
index 0000000..eab2290
--- /dev/null
+++ b/app/src/shared/ui/date-picker/DatePicker.tsx
@@ -0,0 +1,261 @@
+import { useEffect, useId, useRef, useState } from 'react';
+
+import { cn } from '@shared/lib/classnames/cn';
+
+type DatePickerProps = {
+ error?: string;
+ hint?: string;
+ id?: string;
+ label?: string;
+ onChange: (value: string) => void;
+ value: string;
+};
+
+const WEEKDAYS = ['Mo', 'Tu', 'We', 'Th', 'Fr', 'Sa', 'Su'];
+const MONTHS = [
+ 'January',
+ 'February',
+ 'March',
+ 'April',
+ 'May',
+ 'June',
+ 'July',
+ 'August',
+ 'September',
+ 'October',
+ 'November',
+ 'December',
+];
+
+function toDateString(date: Date): string {
+ const y = date.getFullYear();
+ const m = String(date.getMonth() + 1).padStart(2, '0');
+ const d = String(date.getDate()).padStart(2, '0');
+ return `${y}-${m}-${d}`;
+}
+
+function formatDisplay(value: string): string {
+ if (!value) return '';
+ const [year, month, day] = value.split('-');
+ return `${day}.${month}.${year}`;
+}
+
+export function DatePicker({ error, hint, id, label, onChange, value }: DatePickerProps) {
+ const generatedId = useId();
+ const inputId = id ?? generatedId;
+ const description = error ?? hint;
+
+ const [isOpen, setIsOpen] = useState(false);
+
+ const today = new Date();
+ const initialDate = value ? new Date(`${value}T00:00:00`) : today;
+ const [viewDate, setViewDate] = useState(
+ new Date(initialDate.getFullYear(), initialDate.getMonth(), 1),
+ );
+
+ const containerRef = useRef(null);
+
+ useEffect(() => {
+ if (!isOpen) return;
+
+ function handlePointerDown(e: PointerEvent) {
+ if (!containerRef.current?.contains(e.target as Node)) {
+ setIsOpen(false);
+ }
+ }
+
+ function handleKeyDown(e: KeyboardEvent) {
+ if (e.key === 'Escape') setIsOpen(false);
+ }
+
+ document.addEventListener('pointerdown', handlePointerDown);
+ document.addEventListener('keydown', handleKeyDown);
+ return () => {
+ document.removeEventListener('pointerdown', handlePointerDown);
+ document.removeEventListener('keydown', handleKeyDown);
+ };
+ }, [isOpen]);
+
+ function moveMonth(direction: -1 | 1) {
+ setViewDate((d) => new Date(d.getFullYear(), d.getMonth() + direction, 1));
+ }
+
+ const firstWeekday = (viewDate.getDay() + 6) % 7;
+ const daysInMonth = new Date(viewDate.getFullYear(), viewDate.getMonth() + 1, 0).getDate();
+ const todayStr = toDateString(today);
+
+ return (
+
+ {label ?
{label} : null}
+
+
setIsOpen((o) => !o)}
+ type="button"
+ >
+ {value ? formatDisplay(value) : 'dd.mm.yyyy'}
+
+
+
+ {description ? (
+
+ {description}
+
+ ) : null}
+
+ {isOpen ? (
+
+
+ moveMonth(-1)}
+ type="button"
+ >
+
+
+
+ {MONTHS[viewDate.getMonth()]} {viewDate.getFullYear()}
+
+ moveMonth(1)}
+ type="button"
+ >
+
+
+
+
+
+
+ {WEEKDAYS.map((day) => (
+
+ {day}
+
+ ))}
+
+
+
+ {Array.from({ length: firstWeekday }, (_, i) => (
+
+ ))}
+ {Array.from({ length: daysInMonth }, (_, i) => {
+ const day = i + 1;
+ const dateStr = toDateString(
+ new Date(viewDate.getFullYear(), viewDate.getMonth(), day),
+ );
+ const isSelected = dateStr === value;
+ const isToday = dateStr === todayStr;
+
+ return (
+ {
+ onChange(dateStr);
+ setIsOpen(false);
+ }}
+ type="button"
+ >
+ {day}
+
+ );
+ })}
+
+
+
+
+ {
+ onChange('');
+ setIsOpen(false);
+ }}
+ type="button"
+ >
+ Clear
+
+ {
+ const t = new Date();
+ setViewDate(new Date(t.getFullYear(), t.getMonth(), 1));
+ onChange(toDateString(t));
+ setIsOpen(false);
+ }}
+ type="button"
+ >
+ Today
+
+
+
+ ) : null}
+
+ );
+}
+
+function CalendarIcon() {
+ return (
+
+
+
+ );
+}
+
+function ChevronLeftIcon() {
+ return (
+
+
+
+ );
+}
+
+function ChevronRightIcon() {
+ return (
+
+
+
+ );
+}
diff --git a/app/src/shared/ui/date-picker/index.ts b/app/src/shared/ui/date-picker/index.ts
new file mode 100644
index 0000000..a4eb7f5
--- /dev/null
+++ b/app/src/shared/ui/date-picker/index.ts
@@ -0,0 +1 @@
+export { DatePicker } from './DatePicker';
diff --git a/app/src/shared/ui/empty-state/EmptyState.tsx b/app/src/shared/ui/empty-state/EmptyState.tsx
index 696521a..610d362 100644
--- a/app/src/shared/ui/empty-state/EmptyState.tsx
+++ b/app/src/shared/ui/empty-state/EmptyState.tsx
@@ -6,6 +6,8 @@ type EmptyStateProps = {
className?: string;
description: string;
onAction?: () => void;
+ onSecondAction?: () => void;
+ secondActionLabel?: string;
title: string;
};
@@ -14,6 +16,8 @@ export function EmptyState({
className,
description,
onAction,
+ onSecondAction,
+ secondActionLabel,
title,
}: EmptyStateProps) {
return (
@@ -26,9 +30,14 @@ export function EmptyState({
{title}
{description}
{actionLabel && onAction ? (
-
- {actionLabel}
-
+
+ {actionLabel}
+ {secondActionLabel && onSecondAction ? (
+
+ {secondActionLabel}
+
+ ) : null}
+
) : null}
);
diff --git a/app/src/widgets/transactions-history/ui/TransactionsHistory.tsx b/app/src/widgets/transactions-history/ui/TransactionsHistory.tsx
index 33e17c9..a1e5cc4 100644
--- a/app/src/widgets/transactions-history/ui/TransactionsHistory.tsx
+++ b/app/src/widgets/transactions-history/ui/TransactionsHistory.tsx
@@ -12,6 +12,7 @@ import {
import { Badge, type BadgeVariant } from '@shared/ui/badge';
import { Button } from '@shared/ui/button';
import { Card } from '@shared/ui/card';
+import { DatePicker } from '@shared/ui/date-picker';
import { EmptyState } from '@shared/ui/empty-state';
import { Input } from '@shared/ui/input';
import { Skeleton } from '@shared/ui/skeleton';
@@ -55,24 +56,6 @@ function formatSyncStatus(status: Transaction['syncStatus']) {
return status[0].toUpperCase() + status.slice(1);
}
-function formatDateLabel(value: string) {
- if (!value) {
- return 'dd.mm.yyyy';
- }
-
- const [year, month, day] = value.split('-');
-
- return `${day}.${month}.${year}`;
-}
-
-function toDateValue(date: Date) {
- const year = date.getFullYear();
- const month = String(date.getMonth() + 1).padStart(2, '0');
- const day = String(date.getDate()).padStart(2, '0');
-
- return `${year}-${month}-${day}`;
-}
-
function useOutsideClose(isOpen: boolean, onClose: () => void) {
const ref = useRef
(null);
@@ -194,135 +177,6 @@ function FilterSelect({ id, label, onChange, options, value }: FilterSelectProps
);
}
-type DateFilterFieldProps = {
- id: string;
- label: string;
- onChange: (value: string) => void;
- value: string;
-};
-
-function DateFilterField({ id, label, onChange, value }: DateFilterFieldProps) {
- const selectedDate = value ? new Date(`${value}T00:00:00`) : new Date();
- const [isOpen, setIsOpen] = useState(false);
- const [visibleMonth, setVisibleMonth] = useState(
- new Date(selectedDate.getFullYear(), selectedDate.getMonth(), 1),
- );
- const wrapperRef = useOutsideClose(isOpen, () => setIsOpen(false));
- const monthLabel = visibleMonth.toLocaleDateString('en-US', {
- month: 'long',
- year: 'numeric',
- });
- const firstWeekday = (visibleMonth.getDay() + 6) % 7;
- const daysInMonth = new Date(
- visibleMonth.getFullYear(),
- visibleMonth.getMonth() + 1,
- 0,
- ).getDate();
- const calendarDays = [
- ...Array.from({ length: firstWeekday }, () => null),
- ...Array.from(
- { length: daysInMonth },
- (_, index) => new Date(visibleMonth.getFullYear(), visibleMonth.getMonth(), index + 1),
- ),
- ];
-
- function moveMonth(direction: -1 | 1) {
- setVisibleMonth(
- (current) => new Date(current.getFullYear(), current.getMonth() + direction, 1),
- );
- }
-
- return (
-
-
{label}
-
setIsOpen((current) => !current)}
- type="button"
- >
- {formatDateLabel(value)}
- ▦
-
- {isOpen ? (
-
-
-
moveMonth(-1)}
- type="button"
- >
- ←
-
-
{monthLabel}
-
moveMonth(1)}
- type="button"
- >
- →
-
-
-
- {['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun'].map((day) => (
- {day}
- ))}
-
-
- {calendarDays.map((date, index) =>
- date ? (
- {
- onChange(toDateValue(date));
- setIsOpen(false);
- }}
- type="button"
- >
- {date.getDate()}
-
- ) : (
-
- ),
- )}
-
-
- {
- onChange('');
- setIsOpen(false);
- }}
- type="button"
- >
- Clear
-
- {
- const today = new Date();
-
- setVisibleMonth(new Date(today.getFullYear(), today.getMonth(), 1));
- onChange(toDateValue(today));
- setIsOpen(false);
- }}
- type="button"
- >
- Today
-
-
-
- ) : null}
-
- );
-}
-
type RowActionsProps = {
onDelete: () => void | Promise;
onEdit: () => void;
@@ -460,6 +314,8 @@ export function TransactionsHistory({
actionLabel="Create transaction"
description="Transactions from Ledger will appear here after the first sync."
onAction={onCreateTransaction}
+ onSecondAction={() => void onRefresh()}
+ secondActionLabel="Refresh"
title="No transactions yet"
/>
);
@@ -550,13 +406,13 @@ export function TransactionsHistory({
+ );
+}
+
+function ChevronDownIcon({ className }: { className?: string }) {
+ return (
+
+ );
+}
+
+function CheckIcon({ className }: { className?: string }) {
+ return (
+
);
}
diff --git a/app/src/shared/ui/select/index.ts b/app/src/shared/ui/select/index.ts
index 205709b..95658d5 100644
--- a/app/src/shared/ui/select/index.ts
+++ b/app/src/shared/ui/select/index.ts
@@ -1 +1,2 @@
export { Select } from './Select';
+export type { SelectOption } from './Select';
diff --git a/app/src/widgets/transactions-history/ui/TransactionsHistory.tsx b/app/src/widgets/transactions-history/ui/TransactionsHistory.tsx
index a1e5cc4..07cc494 100644
--- a/app/src/widgets/transactions-history/ui/TransactionsHistory.tsx
+++ b/app/src/widgets/transactions-history/ui/TransactionsHistory.tsx
@@ -15,6 +15,7 @@ import { Card } from '@shared/ui/card';
import { DatePicker } from '@shared/ui/date-picker';
import { EmptyState } from '@shared/ui/empty-state';
import { Input } from '@shared/ui/input';
+import { Select } from '@shared/ui/select';
import { Skeleton } from '@shared/ui/skeleton';
type TransactionsHistoryProps = {
@@ -121,62 +122,6 @@ function TransactionsHistorySkeleton() {
);
}
-type FilterOption = {
- label: string;
- value: string;
-};
-
-type FilterSelectProps = {
- id: string;
- label: string;
- onChange: (value: string) => void;
- options: FilterOption[];
- value: string;
-};
-
-function FilterSelect({ id, label, onChange, options, value }: FilterSelectProps) {
- const [isOpen, setIsOpen] = useState(false);
- const selectedOption = options.find((option) => option.value === value) ?? options[0];
- const wrapperRef = useOutsideClose(isOpen, () => setIsOpen(false));
-
- return (
-