feat(web): localize categories to Mongolian + icon/color chips; fix transaction date sort; add deploy Dockerfile

This commit is contained in:
Munkherdene 2026-08-22 21:58:33 +08:00
parent 6913c1e5ee
commit 06e74b1668
8 changed files with 148 additions and 11 deletions

10
.dockerignore Normal file
View file

@ -0,0 +1,10 @@
node_modules
.next
.git
.env*
!.env.local.example
npm-debug.log*
.DS_Store
test-results
playwright-report
e2e

21
Dockerfile Normal file
View file

@ -0,0 +1,21 @@
# Mercury web — production image. Build with bun, run the Next standalone
# server bundle under Node. FMS_API_URL is injected at runtime (server-side
# proxy target), never baked into the build.
FROM oven/bun:1 AS builder
WORKDIR /app
COPY package.json bun.lock ./
RUN bun install --frozen-lockfile
COPY . .
RUN bun run build
FROM node:22-alpine AS runner
WORKDIR /app
ENV NODE_ENV=production
ENV PORT=3000
ENV HOSTNAME=0.0.0.0
# Next standalone output: minimal server + traced node_modules, plus static assets.
COPY --from=builder /app/.next/standalone ./
COPY --from=builder /app/.next/static ./.next/static
COPY --from=builder /app/public ./public
EXPOSE 3000
CMD ["node", "server.js"]

View file

@ -1,5 +1,9 @@
import type { NextConfig } from "next"; import type { NextConfig } from "next";
const nextConfig: NextConfig = {}; const nextConfig: NextConfig = {
// Emit a self-contained server bundle (.next/standalone) for a lean
// production Docker image (`node server.js`).
output: "standalone",
};
export default nextConfig; export default nextConfig;

70
src/ds/categoryStyle.ts Normal file
View file

@ -0,0 +1,70 @@
// Mongolian display name + icon + color for a backend category. Categories are
// stored under English names; this is the single place that maps each to how it
// reads and looks in the UI. Ported from ios/Mercury/Features/Transactions/
// CategoryStyle.swift — the web has no Seed multicolor-icon catalog, so each
// icon is an emoji + a soft color tint for the category chip.
export interface CategoryStyle {
name: string; // Mongolian display name
emoji: string;
tint: string; // chip background (soft)
fg: string; // chip foreground / accent
}
const s = (name: string, emoji: string, tint: string, fg: string): CategoryStyle => ({ name, emoji, tint, fg });
const TABLE: Record<string, CategoryStyle> = {
// Food & drink
"food & drink": s("Хоол хүнс", "🍴", "#FDE8D7", "#B4530A"),
groceries: s("Хүнсний бараа", "🛒", "#E3F0D8", "#3F7A1E"),
dining: s("Хоолны газар", "🍱", "#FDE8D7", "#B4530A"),
dinner: s("Оройн хоол", "🍽️", "#FDE8D7", "#B4530A"),
coffee: s("Кофе", "☕", "#EEE3D7", "#7A5230"),
drink: s("Ундаа", "🥤", "#DDEAF6", "#215C9A"),
// Transport
transport: s("Тээвэр", "🚗", "#DDE6F6", "#2A4B9A"),
// Bills & services
"bills & services": s("Төлбөр & үйлчилгээ", "💳", "#E7E3F6", "#5B44B0"),
fees: s("Шимтгэл", "🧾", "#EDEBE7", "#6B6257"),
insurance: s("Даатгал", "🛡️", "#DFEFEA", "#1E7A63"),
phone: s("Утас", "📱", "#E1E5EA", "#42505F"),
subscriptions: s("Сабскрипшн", "✨", "#F5E7F0", "#9A2E77"),
"үйлчилгээ": s("Үйлчилгээ", "🔧", "#EDEBE7", "#6B6257"),
// Debt & loans
"debt & loans": s("Зээл & өр", "🛡️", "#F6E3E3", "#A83232"),
debt: s("Өр", "🛡️", "#F6E3E3", "#A83232"),
loan: s("Зээл", "💳", "#E7E3F6", "#5B44B0"),
// Shopping
shopping: s("Дэлгүүр", "🛍️", "#F5E7F0", "#9A2E77"),
electronics: s("Цахилгаан бараа", "🖥️", "#E1E5EA", "#42505F"),
// Entertainment
entertainment: s("Зугаа цэнгэл", "🎮", "#E7E3F6", "#5B44B0"),
anime: s("Аниме", "🎮", "#E7E3F6", "#5B44B0"),
spotify: s("Spotify", "🎵", "#E3F0D8", "#3F7A1E"),
// Health & care
fitness: s("Фитнес", "🏋️", "#DDEAF6", "#215C9A"),
haircut: s("Үс засалт", "✂️", "#F5E7F0", "#9A2E77"),
// Money movement / income
transfers: s("Шилжүүлэг", "🔁", "#E1E5EA", "#42505F"),
salary: s("Цалин", "💰", "#E3F0D8", "#3F7A1E"),
// Synthetic budget line for the loan repayment.
"зээлийн төлбөр": s("Зээлийн төлбөр", "🛡️", "#F6E3E3", "#A83232"),
};
const INCOME_FALLBACK = s("Орлого", "💰", "#E3F0D8", "#3F7A1E");
const OTHER_FALLBACK = s("Бусад", "🧾", "#EDEBE7", "#6B6257");
/** Look up the style for a backend category name. `income` decides the fallback
* when the category is empty or unknown. Mirrors CategoryStyle.of in iOS. */
export function categoryStyle(category: string | null | undefined, income = false): CategoryStyle {
const key = (category ?? "").trim().toLowerCase();
const hit = TABLE[key];
if (hit) return hit;
if (income) return INCOME_FALLBACK;
return key ? { ...OTHER_FALLBACK, name: category as string } : OTHER_FALLBACK;
}
/** Just the Mongolian display name. */
export function categoryName(category: string | null | undefined, income = false): string {
return categoryStyle(category, income).name;
}

View file

@ -15,3 +15,4 @@ export type { NameEditProps } from "./NameEdit";
export { Card } from "./Card"; export { Card } from "./Card";
export type { CardProps } from "./Card"; export type { CardProps } from "./Card";
export * from "./categoryStyle";

View file

@ -6,6 +6,7 @@ import { Skeleton } from "@seed-design/react";
import { useTransactions } from "@/api/hooks/reads"; import { useTransactions } from "@/api/hooks/reads";
import type { Txn } from "@/api/schemas"; import type { Txn } from "@/api/schemas";
import { Card, HideAmountsToggle } from "@/ds"; import { Card, HideAmountsToggle } from "@/ds";
import { categoryStyle } from "@/ds/categoryStyle";
import { MASKED, tugrikRaw } from "@/ds/money"; import { MASKED, tugrikRaw } from "@/ds/money";
import { accountingStrings as s } from "./strings"; import { accountingStrings as s } from "./strings";
import { txnRouteId } from "./txnRoute"; import { txnRouteId } from "./txnRoute";
@ -74,6 +75,7 @@ function TxnRow({ txn, hidden }: { txn: Txn; hidden: boolean }) {
: income : income
? "var(--mercury-success, #1e9e6b)" ? "var(--mercury-success, #1e9e6b)"
: "var(--mercury-critical, #e5484d)"; : "var(--mercury-critical, #e5484d)";
const cat = categoryStyle(txn.category, income);
return ( return (
<Link <Link
@ -83,19 +85,36 @@ function TxnRow({ txn, hidden }: { txn: Txn; hidden: boolean }) {
alignItems: "center", alignItems: "center",
justifyContent: "space-between", justifyContent: "space-between",
gap: 12, gap: 12,
padding: "12px 0", padding: "10px 0",
textDecoration: "none", textDecoration: "none",
color: "inherit", color: "inherit",
}} }}
> >
<div style={{ display: "flex", flexDirection: "column", gap: 4, minWidth: 0 }}> <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",
fontSize: 19,
background: isTransfer ? "var(--seed-color-bg-neutral-subtle, #eef0f2)" : cat.tint,
}}
>
{isTransfer ? "🔁" : cat.emoji}
</span>
<div style={{ display: "flex", flexDirection: "column", gap: 2, minWidth: 0 }}>
<span style={{ fontSize: 15, fontWeight: 700, overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}> <span style={{ fontSize: 15, fontWeight: 700, overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>
{txn.title || txn.category} {txn.title || cat.name}
</span> </span>
<span style={{ fontSize: 12, color: "var(--seed-color-fg-neutral-muted, #8b8b8b)" }}> <span style={{ fontSize: 12, color: cat.fg }}>
{isTransfer ? `${txn.category} · ${s.list.transferTag}` : txn.category} {isTransfer ? `${cat.name} · ${s.list.transferTag}` : cat.name}
</span> </span>
</div> </div>
</div>
<span style={{ fontSize: 16, fontWeight: 700, color: amountColor, flexShrink: 0 }}>{amountText}</span> <span style={{ fontSize: 16, fontWeight: 700, color: amountColor, flexShrink: 0 }}>{amountText}</span>
</Link> </Link>
); );
@ -125,7 +144,14 @@ export function TransactionList() {
return { income, expense }; return { income, expense };
}, [all]); }, [all]);
const visible = useMemo(() => all.filter((txn) => txn.salary !== true), [all]); const visible = useMemo(
() =>
all
.filter((txn) => txn.salary !== true)
.slice()
.sort((a, b) => new Date(b.date).getTime() - new Date(a.date).getTime()),
[all],
);
const groups = useMemo(() => groupByDay(visible), [visible]); const groups = useMemo(() => groupByDay(visible), [visible]);
return ( return (

View file

@ -2,6 +2,7 @@ import type { NetWorth } from "../../api/schemas/networth";
import type { Analyze } from "../../api/schemas/analyze"; import type { Analyze } from "../../api/schemas/analyze";
import type { Budget } from "../../api/schemas/budget"; import type { Budget } from "../../api/schemas/budget";
import { tugrik, tugrikRaw } from "../../ds/money"; import { tugrik, tugrikRaw } from "../../ds/money";
import { categoryName } from "../../ds/categoryStyle";
import { sample } from "./sample"; import { sample } from "./sample";
/** Parse a backend decimal string ("900000") into a number. Mirrors /** Parse a backend decimal string ("900000") into a number. Mirrors
@ -100,7 +101,7 @@ export function buildHome({ netWorth, month, today, budget }: BuildHomeInput): H
.sort((a, b) => dec(b.limit) - dec(a.limit)) .sort((a, b) => dec(b.limit) - dec(a.limit))
.slice(0, 3) .slice(0, 3)
.map((row) => ({ .map((row) => ({
name: row.category, name: categoryName(row.category),
spent: dec(row.spent), spent: dec(row.spent),
range: tugrikRaw(dec(row.limit)), range: tugrikRaw(dec(row.limit)),
})); }));

View file

@ -22,6 +22,7 @@ import {
Skeleton, Skeleton,
} from "@seed-design/react"; } from "@seed-design/react";
import { Card, MercuryButton, HideAmountsToggle, NameEdit } from "@/ds"; import { Card, MercuryButton, HideAmountsToggle, NameEdit } from "@/ds";
import { categoryStyle } from "@/ds/categoryStyle";
import { tugrik, tugrikShort } from "@/ds/money"; import { tugrik, tugrikShort } from "@/ds/money";
import { useBudget, useNetWorth } from "@/api/hooks/reads"; import { useBudget, useNetWorth } from "@/api/hooks/reads";
import { usePutBudget, useSavingsGoalMutations } from "@/api/hooks/mutations"; import { usePutBudget, useSavingsGoalMutations } from "@/api/hooks/mutations";
@ -478,7 +479,10 @@ function CategoryList({
style={{ color: "inherit", textDecoration: "none" }} style={{ color: "inherit", textDecoration: "none" }}
> >
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<span style={{ fontWeight: 700 }}>{row.category}</span> <span style={{ display: "inline-flex", alignItems: "center", gap: 8, fontWeight: 700 }}>
<span aria-hidden>{categoryStyle(row.category).emoji}</span>
{categoryStyle(row.category).name}
</span>
<span style={{ fontWeight: 700, color: over ? "var(--seed-color-fg-critical)" : "var(--seed-color-fg-placeholder)" }}> <span style={{ fontWeight: 700, color: over ? "var(--seed-color-fg-critical)" : "var(--seed-color-fg-placeholder)" }}>
{tugrik(spent)} {tugrik(spent)}
{limit > 0 ? ` / ${tugrik(limit)}` : " / —"} {limit > 0 ? ` / ${tugrik(limit)}` : " / —"}