feat(web): transactions month nav + category filters + categorize-review queue
Adds a month navigator and category filter chips to the Тооцоо list, plus a categorize-review flow at /accounting/review for bulk-assigning categories to uncategorized merchants (grouped by matchKey, biggest spend first).
This commit is contained in:
parent
5eff0f661f
commit
e478ca5826
9 changed files with 460 additions and 27 deletions
6
src/app/(app)/accounting/review/page.tsx
Normal file
6
src/app/(app)/accounting/review/page.tsx
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
import { CategorizeReview } from "@/features/accounting/CategorizeReview";
|
||||
|
||||
// Ports ios/Mercury/Features/Categories/CategorizeReviewView.swift.
|
||||
export default function CategorizeReviewPage() {
|
||||
return <CategorizeReview />;
|
||||
}
|
||||
176
src/features/accounting/CategorizeReview.tsx
Normal file
176
src/features/accounting/CategorizeReview.tsx
Normal file
|
|
@ -0,0 +1,176 @@
|
|||
"use client";
|
||||
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useCategories, useTransactions, todayLocalDate } from "@/api/hooks/reads";
|
||||
import { useCategorize } from "@/api/hooks/mutations";
|
||||
import type { Txn } from "@/api/schemas";
|
||||
import { Card, EmptyState, IconChip, MercuryButton } from "@/ds";
|
||||
import { categoryStyle } from "@/ds/categoryStyle";
|
||||
import { Icon } from "@/ds/icons";
|
||||
import { MASKED, tugrikRaw } from "@/ds/money";
|
||||
import { accountingStrings as s } from "./strings";
|
||||
import { useHiddenAmounts } from "./useHiddenAmounts";
|
||||
|
||||
interface ReviewItem {
|
||||
/** Groups by `matchKey` (falling back to `title`) — the same key the
|
||||
* categorize endpoint applies the rule to. */
|
||||
matchKey: string;
|
||||
merchant: string;
|
||||
count: number;
|
||||
direction: "income" | "expense";
|
||||
total: number;
|
||||
}
|
||||
|
||||
function threeMonthsAgo(base: Date = new Date()): Date {
|
||||
return new Date(base.getFullYear(), base.getMonth() - 3, base.getDate());
|
||||
}
|
||||
|
||||
/** Groups uncategorized transactions by matchKey, biggest total spend first —
|
||||
* a handful of taps then covers most of the uncategorized money instead of
|
||||
* burning through many trivial merchants. Ports the grouping in
|
||||
* `CategorizeReviewModel.load()`. */
|
||||
function buildQueue(txns: Txn[]): ReviewItem[] {
|
||||
const groups = new Map<string, ReviewItem>();
|
||||
for (const t of txns) {
|
||||
if (t.category) continue; // defensive — the fetch already scopes to Uncategorized
|
||||
const key = t.matchKey || t.title;
|
||||
if (!key) continue;
|
||||
const amount = parseFloat(t.amount) || 0;
|
||||
const existing = groups.get(key);
|
||||
if (existing) {
|
||||
existing.count += 1;
|
||||
existing.total += amount;
|
||||
} else {
|
||||
groups.set(key, {
|
||||
matchKey: key,
|
||||
merchant: t.title || key,
|
||||
count: 1,
|
||||
direction: t.direction === "income" ? "income" : "expense",
|
||||
total: amount,
|
||||
});
|
||||
}
|
||||
}
|
||||
return Array.from(groups.values()).sort((a, b) => b.total - a.total);
|
||||
}
|
||||
|
||||
/**
|
||||
* Full-screen categorize-review flow at `/accounting/review` (ports
|
||||
* `CategorizeReviewView.swift`): groups the last three months' uncategorized
|
||||
* transactions by merchant, biggest spend first, and asks the user to assign
|
||||
* or skip a category one merchant at a time.
|
||||
*/
|
||||
export function CategorizeReview() {
|
||||
const router = useRouter();
|
||||
const { from, to } = useMemo(() => {
|
||||
const now = new Date();
|
||||
return { from: todayLocalDate(threeMonthsAgo(now)), to: todayLocalDate(now) };
|
||||
}, []);
|
||||
const { data, isLoading } = useTransactions({ from, to, category: "Uncategorized", limit: 500 });
|
||||
const { data: categories = [] } = useCategories();
|
||||
const categorize = useCategorize();
|
||||
const hidden = useHiddenAmounts();
|
||||
|
||||
// The queue is seeded once from the fetch, then mutated locally (skip
|
||||
// removes, assign removes on success) — re-deriving it from `data` on every
|
||||
// background refetch (categorize invalidates the transactions cache) would
|
||||
// otherwise re-insert items the user already handled in this session.
|
||||
const [queue, setQueue] = useState<ReviewItem[] | null>(null);
|
||||
useEffect(() => {
|
||||
if (data && queue === null) setQueue(buildQueue(data));
|
||||
}, [data, queue]);
|
||||
|
||||
const mainCategories = useMemo(() => categories.filter((c) => c.depth === 1), [categories]);
|
||||
|
||||
function close() {
|
||||
router.push("/accounting");
|
||||
}
|
||||
|
||||
function skip(item: ReviewItem) {
|
||||
setQueue((q) => (q ? q.filter((i) => i.matchKey !== item.matchKey) : q));
|
||||
}
|
||||
|
||||
function assign(item: ReviewItem, category: string) {
|
||||
categorize.mutate(
|
||||
{ matchKey: item.matchKey, category, kind: item.direction },
|
||||
{ onSuccess: () => setQueue((q) => (q ? q.filter((i) => i.matchKey !== item.matchKey) : q)) },
|
||||
);
|
||||
}
|
||||
|
||||
const current = queue?.[0];
|
||||
|
||||
return (
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: 20 }}>
|
||||
<div style={{ display: "flex", alignItems: "center", justifyContent: "space-between" }}>
|
||||
<h1 style={{ fontSize: 18, fontWeight: 700, margin: 0 }}>{s.review.title}</h1>
|
||||
<button
|
||||
type="button"
|
||||
onClick={close}
|
||||
style={{ all: "unset", cursor: "pointer", fontSize: 14, color: "var(--seed-color-fg-neutral-muted, #8b8b8b)" }}
|
||||
>
|
||||
{s.review.later}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{isLoading && queue === null ? (
|
||||
<Card style={{ minHeight: 320 }} />
|
||||
) : current ? (
|
||||
<>
|
||||
<p style={{ margin: 0, fontSize: 13, color: "var(--seed-color-fg-neutral-muted, #8b8b8b)" }}>
|
||||
{s.review.remaining(queue!.length)}
|
||||
</p>
|
||||
|
||||
<Card style={{ display: "flex", flexDirection: "column", alignItems: "center", gap: 8, padding: "24px 16px" }}>
|
||||
<IconChip icon="cart" tint="var(--seed-color-bg-neutral-subtle, #eef0f2)" fg="var(--seed-color-fg-neutral)" size={56} />
|
||||
<span style={{ fontSize: 16, fontWeight: 700, textAlign: "center" }}>{current.merchant}</span>
|
||||
<span style={{ fontSize: 12, color: "var(--seed-color-fg-neutral-muted, #8b8b8b)" }}>
|
||||
{s.review.transactionCount(current.count)} · {hidden ? MASKED : tugrikRaw(current.total)}
|
||||
</span>
|
||||
</Card>
|
||||
|
||||
<p style={{ margin: 0, fontSize: 14, fontWeight: 700 }}>{s.review.question}</p>
|
||||
|
||||
<div style={{ display: "flex", flexWrap: "wrap", gap: 10 }}>
|
||||
{mainCategories.map((cat) => {
|
||||
const style = categoryStyle(cat.name, cat.kind === "income");
|
||||
return (
|
||||
<button
|
||||
key={cat.name}
|
||||
type="button"
|
||||
onClick={() => assign(current, cat.name)}
|
||||
disabled={categorize.isPending}
|
||||
style={{
|
||||
all: "unset",
|
||||
cursor: categorize.isPending ? "default" : "pointer",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: 6,
|
||||
padding: "10px 14px",
|
||||
borderRadius: 999,
|
||||
fontSize: 14,
|
||||
opacity: categorize.isPending ? 0.6 : 1,
|
||||
background: "var(--seed-color-bg-neutral-subtle, #eef0f2)",
|
||||
}}
|
||||
>
|
||||
<Icon name={style.icon} size={16} />
|
||||
{style.name}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
<MercuryButton variant="secondary" onClick={() => skip(current)}>
|
||||
{s.review.skip}
|
||||
</MercuryButton>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<EmptyState icon="calendar-check" title={s.review.done} />
|
||||
<MercuryButton variant="primary" onClick={close}>
|
||||
{s.review.close}
|
||||
</MercuryButton>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
89
src/features/accounting/CategoryChips.tsx
Normal file
89
src/features/accounting/CategoryChips.tsx
Normal file
|
|
@ -0,0 +1,89 @@
|
|||
"use client";
|
||||
|
||||
import { categoryStyle } from "@/ds/categoryStyle";
|
||||
import { Icon } from "@/ds/icons";
|
||||
import { accountingStrings as s } from "./strings";
|
||||
|
||||
export interface CategoryChipOption {
|
||||
/** Raw backend category name (as stored on `Txn.category`). */
|
||||
name: string;
|
||||
/** Direction of a representative transaction in that category — decides
|
||||
* the fallback style for an unrecognized category name. */
|
||||
income: boolean;
|
||||
}
|
||||
|
||||
export interface CategoryChipsProps {
|
||||
categories: CategoryChipOption[];
|
||||
/** `null` = "Бүгд" (all, no filter). */
|
||||
selected: string | null;
|
||||
onSelect: (category: string | null) => void;
|
||||
}
|
||||
|
||||
/** Horizontal, scrollable category filter row above the Тооцоо list: "Бүгд"
|
||||
* plus every category present in the loaded month, tinted with
|
||||
* `categoryStyle` and filled when active. Tap to filter the visible rows. */
|
||||
export function CategoryChips({ categories, selected, onSelect }: CategoryChipsProps) {
|
||||
if (categories.length === 0) return null;
|
||||
return (
|
||||
<div style={{ display: "flex", gap: 8, overflowX: "auto", paddingBottom: 2 }}>
|
||||
<Chip label={s.list.allCategories} active={selected === null} onClick={() => onSelect(null)} />
|
||||
{categories.map((c) => {
|
||||
const style = categoryStyle(c.name, c.income);
|
||||
return (
|
||||
<Chip
|
||||
key={c.name}
|
||||
label={style.name}
|
||||
icon={style.icon}
|
||||
active={selected === c.name}
|
||||
activeTint={style.tint}
|
||||
activeFg={style.fg}
|
||||
onClick={() => onSelect(c.name)}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Chip({
|
||||
label,
|
||||
icon,
|
||||
active,
|
||||
activeTint,
|
||||
activeFg,
|
||||
onClick,
|
||||
}: {
|
||||
label: string;
|
||||
icon?: ReturnType<typeof categoryStyle>["icon"];
|
||||
active: boolean;
|
||||
activeTint?: string;
|
||||
activeFg?: string;
|
||||
onClick: () => void;
|
||||
}) {
|
||||
const tint = active ? (activeTint ?? "var(--seed-color-fg-neutral)") : "var(--seed-color-bg-neutral-subtle, #eef0f2)";
|
||||
const fg = active ? (activeFg ?? "var(--seed-color-bg-layer-floating, #fff)") : "var(--seed-color-fg-neutral)";
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
style={{
|
||||
all: "unset",
|
||||
cursor: "pointer",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: 6,
|
||||
flexShrink: 0,
|
||||
height: 32,
|
||||
padding: "0 12px",
|
||||
borderRadius: 16,
|
||||
fontSize: 14,
|
||||
fontWeight: active ? 700 : 500,
|
||||
color: fg,
|
||||
background: tint,
|
||||
}}
|
||||
>
|
||||
{icon ? <Icon name={icon} size={15} /> : null}
|
||||
{label}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
42
src/features/accounting/MonthNav.tsx
Normal file
42
src/features/accounting/MonthNav.tsx
Normal file
|
|
@ -0,0 +1,42 @@
|
|||
"use client";
|
||||
|
||||
import type { CSSProperties } from "react";
|
||||
import { Icon } from "@/ds/icons";
|
||||
|
||||
const navButtonStyle: CSSProperties = {
|
||||
all: "unset",
|
||||
cursor: "pointer",
|
||||
width: 32,
|
||||
height: 32,
|
||||
flexShrink: 0,
|
||||
borderRadius: 10,
|
||||
display: "grid",
|
||||
placeItems: "center",
|
||||
color: "var(--seed-color-fg-neutral)",
|
||||
};
|
||||
|
||||
export interface MonthNavProps {
|
||||
/** e.g. "2026 оны 8-р сар" (see `monthLabel`). */
|
||||
label: string;
|
||||
onPrev: () => void;
|
||||
onNext: () => void;
|
||||
prevLabel?: string;
|
||||
nextLabel?: string;
|
||||
}
|
||||
|
||||
/** ‹ [month] › — the month navigator above the Тооцоо list. Ports the
|
||||
* `monthRow` control from `TransactionsView.swift` (narrowed to just the
|
||||
* month stepper, no week/month panel toggle). */
|
||||
export function MonthNav({ label, onPrev, onNext, prevLabel = "Өмнөх сар", nextLabel = "Дараагийн сар" }: MonthNavProps) {
|
||||
return (
|
||||
<div style={{ display: "flex", alignItems: "center", justifyContent: "center", gap: 4 }}>
|
||||
<button type="button" onClick={onPrev} aria-label={prevLabel} style={navButtonStyle}>
|
||||
<Icon name="chevron-left" size={16} />
|
||||
</button>
|
||||
<span style={{ fontSize: 17, fontWeight: 700, minWidth: 150, textAlign: "center" }}>{label}</span>
|
||||
<button type="button" onClick={onNext} aria-label={nextLabel} style={navButtonStyle}>
|
||||
<Icon name="chevron-right" size={16} />
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -27,6 +27,12 @@ const txns: Txn[] = [
|
|||
|
||||
vi.mock("@/api/hooks/reads", () => ({
|
||||
useTransactions: () => ({ data: txns, isLoading: false }),
|
||||
todayLocalDate: (d: Date = new Date()) => {
|
||||
const year = d.getFullYear();
|
||||
const month = String(d.getMonth() + 1).padStart(2, "0");
|
||||
const day = String(d.getDate()).padStart(2, "0");
|
||||
return `${year}-${month}-${day}`;
|
||||
},
|
||||
}));
|
||||
|
||||
import { TransactionList } from "./TransactionList";
|
||||
|
|
|
|||
|
|
@ -1,14 +1,16 @@
|
|||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { useMemo } from "react";
|
||||
import { useMemo, useState } from "react";
|
||||
import { Skeleton } from "@seed-design/react";
|
||||
import { useTransactions } from "@/api/hooks/reads";
|
||||
import type { Txn } from "@/api/schemas";
|
||||
import { Card, HideAmountsToggle } from "@/ds";
|
||||
import { Card, EmptyState, HideAmountsToggle, IconChip, MercuryButton } from "@/ds";
|
||||
import { categoryStyle } from "@/ds/categoryStyle";
|
||||
import { Icon } from "@/ds/icons";
|
||||
import { MASKED, tugrikRaw } from "@/ds/money";
|
||||
import { CategoryChips, type CategoryChipOption } from "./CategoryChips";
|
||||
import { MonthNav } from "./MonthNav";
|
||||
import { monthLabel, monthRange } from "./monthRange";
|
||||
import { accountingStrings as s } from "./strings";
|
||||
import { txnRouteId } from "./txnRoute";
|
||||
import { useHiddenAmounts } from "./useHiddenAmounts";
|
||||
|
|
@ -92,21 +94,11 @@ function TxnRow({ txn, hidden }: { txn: Txn; hidden: boolean }) {
|
|||
}}
|
||||
>
|
||||
<div style={{ display: "flex", alignItems: "center", gap: 12, minWidth: 0 }}>
|
||||
<span
|
||||
aria-hidden
|
||||
style={{
|
||||
width: 40,
|
||||
height: 40,
|
||||
flexShrink: 0,
|
||||
borderRadius: 13,
|
||||
display: "grid",
|
||||
placeItems: "center",
|
||||
color: isTransfer ? "var(--seed-color-fg-neutral-muted, #6b7280)" : cat.fg,
|
||||
background: isTransfer ? "var(--seed-color-bg-neutral-subtle, #eef0f2)" : cat.tint,
|
||||
}}
|
||||
>
|
||||
<Icon name={isTransfer ? "repeat" : cat.icon} size={20} />
|
||||
</span>
|
||||
<IconChip
|
||||
icon={isTransfer ? "repeat" : cat.icon}
|
||||
fg={isTransfer ? "var(--seed-color-fg-neutral-muted, #6b7280)" : cat.fg}
|
||||
tint={isTransfer ? "var(--seed-color-bg-neutral-subtle, #eef0f2)" : cat.tint}
|
||||
/>
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: 2, minWidth: 0 }}>
|
||||
<span style={{ fontSize: 15, fontWeight: 700, overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>
|
||||
{txn.title || cat.name}
|
||||
|
|
@ -121,19 +113,42 @@ function TxnRow({ txn, hidden }: { txn: Txn; hidden: boolean }) {
|
|||
);
|
||||
}
|
||||
|
||||
/** Distinct categories present in `txns`, most-frequent first — the source
|
||||
* for the category filter chip row. */
|
||||
function categoriesIn(txns: Txn[]): CategoryChipOption[] {
|
||||
const counts = new Map<string, { count: number; income: boolean }>();
|
||||
for (const txn of txns) {
|
||||
if (!txn.category) continue;
|
||||
const existing = counts.get(txn.category);
|
||||
if (existing) existing.count += 1;
|
||||
else counts.set(txn.category, { count: 1, income: txn.direction === "income" });
|
||||
}
|
||||
return Array.from(counts.entries())
|
||||
.sort((a, b) => b[1].count - a[1].count)
|
||||
.map(([name, v]) => ({ name, income: v.income }));
|
||||
}
|
||||
|
||||
/**
|
||||
* The Тооцоо list: income/expense totals for the loaded month, then the
|
||||
* transaction rows grouped by day. Mirrors `TransactionsView.ledgerTab` /
|
||||
* `TransactionsModel.recompute` (narrowed to this task's scope — no month
|
||||
* nav or category chips): salary deposits (`salary === true`) are hidden by
|
||||
* The Тооцоо list: a month navigator, income/expense totals for the loaded
|
||||
* month, category filter chips, and the transaction rows grouped by day.
|
||||
* Mirrors `TransactionsView.ledgerTab` / `TransactionsModel` (narrowed to
|
||||
* this task's scope): salary deposits (`salary === true`) are hidden by
|
||||
* default, and transfers are excluded from the totals and tagged in the row
|
||||
* meta line instead of colored green/red.
|
||||
*/
|
||||
export function TransactionList() {
|
||||
const { data, isLoading } = useTransactions();
|
||||
const [monthOffset, setMonthOffset] = useState(0);
|
||||
const [selectedCategory, setSelectedCategory] = useState<string | null>(null);
|
||||
const { from, to } = useMemo(() => monthRange(monthOffset), [monthOffset]);
|
||||
const { data, isLoading } = useTransactions({ from, to });
|
||||
const all = useMemo(() => data ?? [], [data]);
|
||||
const hidden = useHiddenAmounts();
|
||||
|
||||
function changeMonth(next: number) {
|
||||
setMonthOffset(next);
|
||||
setSelectedCategory(null);
|
||||
}
|
||||
|
||||
const totals = useMemo(() => {
|
||||
let income = 0;
|
||||
let expense = 0;
|
||||
|
|
@ -145,33 +160,60 @@ export function TransactionList() {
|
|||
return { income, expense };
|
||||
}, [all]);
|
||||
|
||||
const categoryOptions = useMemo(() => categoriesIn(all), [all]);
|
||||
|
||||
const hasUncategorized = useMemo(
|
||||
() => all.some((txn) => !txn.category && (txn.matchKey || txn.title)),
|
||||
[all],
|
||||
);
|
||||
|
||||
const visible = useMemo(
|
||||
() =>
|
||||
all
|
||||
.filter((txn) => txn.salary !== true)
|
||||
.filter((txn) => selectedCategory === null || txn.category === selectedCategory)
|
||||
.slice()
|
||||
.sort((a, b) => new Date(b.date).getTime() - new Date(a.date).getTime()),
|
||||
[all],
|
||||
[all, selectedCategory],
|
||||
);
|
||||
const groups = useMemo(() => groupByDay(visible), [visible]);
|
||||
|
||||
return (
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: 16 }}>
|
||||
<div style={{ display: "flex", alignItems: "center", justifyContent: "space-between" }}>
|
||||
<div style={{ display: "flex", alignItems: "center", justifyContent: "space-between", gap: 12 }}>
|
||||
<h1 style={{ fontSize: 20, fontWeight: 700, margin: 0 }}>{s.list.title}</h1>
|
||||
<HideAmountsToggle label={s.list.hideToggle} />
|
||||
<div style={{ display: "flex", alignItems: "center", gap: 12 }}>
|
||||
{hasUncategorized ? (
|
||||
<Link href="/accounting/review" style={{ textDecoration: "none" }}>
|
||||
<MercuryButton variant="secondary" size="small">
|
||||
{s.list.categorizeCta}
|
||||
</MercuryButton>
|
||||
</Link>
|
||||
) : null}
|
||||
<HideAmountsToggle label={s.list.hideToggle} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<MonthNav
|
||||
label={monthLabel(monthOffset)}
|
||||
onPrev={() => changeMonth(monthOffset - 1)}
|
||||
onNext={() => changeMonth(monthOffset + 1)}
|
||||
prevLabel={s.list.prevMonth}
|
||||
nextLabel={s.list.nextMonth}
|
||||
/>
|
||||
|
||||
<Card style={{ display: "flex", flexDirection: "column", gap: 12 }}>
|
||||
<SummaryRow label={s.list.income} value={totals.income} hidden={hidden} tone="income" />
|
||||
<SummaryRow label={s.list.expense} value={totals.expense} hidden={hidden} tone="expense" />
|
||||
</Card>
|
||||
|
||||
<CategoryChips categories={categoryOptions} selected={selectedCategory} onSelect={setSelectedCategory} />
|
||||
|
||||
{isLoading ? (
|
||||
<Skeleton style={{ height: 320, width: "100%", borderRadius: "var(--seed-radius-r3)" }} />
|
||||
) : groups.length === 0 ? (
|
||||
<Card>
|
||||
<p style={{ margin: 0, color: "var(--seed-color-fg-neutral-muted, #8b8b8b)" }}>{s.list.empty}</p>
|
||||
<EmptyState icon="calendar-check" title={s.list.empty} hint={s.list.emptyHint} compact />
|
||||
</Card>
|
||||
) : (
|
||||
<Card style={{ display: "flex", flexDirection: "column", gap: 20 }}>
|
||||
|
|
|
|||
35
src/features/accounting/monthRange.test.ts
Normal file
35
src/features/accounting/monthRange.test.ts
Normal file
|
|
@ -0,0 +1,35 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
import { monthLabel, monthRange } from "./monthRange";
|
||||
|
||||
describe("monthRange", () => {
|
||||
it("returns the first and last day of the base month at offset 0", () => {
|
||||
expect(monthRange(0, new Date(2026, 7, 15))).toEqual({ from: "2026-08-01", to: "2026-08-31" });
|
||||
});
|
||||
|
||||
it("steps back a month, crossing a year boundary", () => {
|
||||
expect(monthRange(-1, new Date(2026, 0, 10))).toEqual({ from: "2025-12-01", to: "2025-12-31" });
|
||||
});
|
||||
|
||||
it("steps forward a month, crossing a year boundary", () => {
|
||||
expect(monthRange(1, new Date(2025, 11, 20))).toEqual({ from: "2026-01-01", to: "2026-01-31" });
|
||||
});
|
||||
|
||||
it("handles a short month (February, non-leap year)", () => {
|
||||
expect(monthRange(0, new Date(2026, 1, 1))).toEqual({ from: "2026-02-01", to: "2026-02-28" });
|
||||
});
|
||||
|
||||
it("handles a leap-year February", () => {
|
||||
expect(monthRange(0, new Date(2028, 1, 1))).toEqual({ from: "2028-02-01", to: "2028-02-29" });
|
||||
});
|
||||
});
|
||||
|
||||
describe("monthLabel", () => {
|
||||
it("formats as '<year> оны <month>-р сар'", () => {
|
||||
expect(monthLabel(0, new Date(2026, 7, 15))).toBe("2026 оны 8-р сар");
|
||||
});
|
||||
|
||||
it("rolls the year when stepping across January", () => {
|
||||
expect(monthLabel(-1, new Date(2026, 0, 5))).toBe("2025 оны 12-р сар");
|
||||
expect(monthLabel(1, new Date(2025, 11, 5))).toBe("2026 оны 1-р сар");
|
||||
});
|
||||
});
|
||||
22
src/features/accounting/monthRange.ts
Normal file
22
src/features/accounting/monthRange.ts
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
import { todayLocalDate } from "@/api/hooks/reads";
|
||||
|
||||
/**
|
||||
* Local-calendar [from, to] bounds (`YYYY-MM-DD`, matching `?from=&to=`) for
|
||||
* the month `offset` months from `base` — 0 = `base`'s own month, -1 = the
|
||||
* month before, +1 = the month after. Powers the Тооцоо month navigator.
|
||||
*/
|
||||
export function monthRange(offset: number, base: Date = new Date()): { from: string; to: string } {
|
||||
const year = base.getFullYear();
|
||||
const month = base.getMonth() + offset;
|
||||
const first = new Date(year, month, 1);
|
||||
const last = new Date(year, month + 1, 0); // day 0 of next month = this month's last day
|
||||
return { from: todayLocalDate(first), to: todayLocalDate(last) };
|
||||
}
|
||||
|
||||
/** "2026 оны 8-р сар" for the month `offset` months from `base`. */
|
||||
export function monthLabel(offset: number, base: Date = new Date()): string {
|
||||
const year = base.getFullYear();
|
||||
const month = base.getMonth() + offset;
|
||||
const d = new Date(year, month, 1);
|
||||
return `${d.getFullYear()} оны ${d.getMonth() + 1}-р сар`;
|
||||
}
|
||||
|
|
@ -10,7 +10,22 @@ export const accountingStrings = {
|
|||
income: "Орлого",
|
||||
expense: "Зарлага",
|
||||
empty: "Гүйлгээ алга",
|
||||
emptyHint: "Энэ сард гүйлгээ бүртгэгдээгүй байна.",
|
||||
transferTag: "Шилжүүлэг",
|
||||
allCategories: "Бүгд",
|
||||
categorizeCta: "Ангилах",
|
||||
prevMonth: "Өмнөх сар",
|
||||
nextMonth: "Дараагийн сар",
|
||||
},
|
||||
review: {
|
||||
title: "Ангилалжуулах",
|
||||
later: "Дараа нь",
|
||||
remaining: (n: number) => `${n} ангилалгүй худалдагч үлдлээ`,
|
||||
question: "Аль ангилалд хамаарах вэ?",
|
||||
skip: "Алгасах",
|
||||
done: "Бүгд ангилагдлаа",
|
||||
close: "Хаах",
|
||||
transactionCount: (n: number) => `${n} гүйлгээ`,
|
||||
},
|
||||
detail: {
|
||||
total: "Нийт",
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue