diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..c3f89c3 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,10 @@ +node_modules +.next +.git +.env* +!.env.local.example +npm-debug.log* +.DS_Store +test-results +playwright-report +e2e diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..2842b4f --- /dev/null +++ b/Dockerfile @@ -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"] diff --git a/next.config.ts b/next.config.ts index cb651cd..69e0a2e 100644 --- a/next.config.ts +++ b/next.config.ts @@ -1,5 +1,9 @@ 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; diff --git a/src/ds/categoryStyle.ts b/src/ds/categoryStyle.ts new file mode 100644 index 0000000..5e5c933 --- /dev/null +++ b/src/ds/categoryStyle.ts @@ -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 = { + // 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; +} diff --git a/src/ds/index.ts b/src/ds/index.ts index d27da6a..3e95213 100644 --- a/src/ds/index.ts +++ b/src/ds/index.ts @@ -15,3 +15,4 @@ export type { NameEditProps } from "./NameEdit"; export { Card } from "./Card"; export type { CardProps } from "./Card"; +export * from "./categoryStyle"; diff --git a/src/features/accounting/TransactionList.tsx b/src/features/accounting/TransactionList.tsx index ec149ab..6529567 100644 --- a/src/features/accounting/TransactionList.tsx +++ b/src/features/accounting/TransactionList.tsx @@ -6,6 +6,7 @@ import { Skeleton } from "@seed-design/react"; import { useTransactions } from "@/api/hooks/reads"; import type { Txn } from "@/api/schemas"; import { Card, HideAmountsToggle } from "@/ds"; +import { categoryStyle } from "@/ds/categoryStyle"; import { MASKED, tugrikRaw } from "@/ds/money"; import { accountingStrings as s } from "./strings"; import { txnRouteId } from "./txnRoute"; @@ -74,6 +75,7 @@ function TxnRow({ txn, hidden }: { txn: Txn; hidden: boolean }) { : income ? "var(--mercury-success, #1e9e6b)" : "var(--mercury-critical, #e5484d)"; + const cat = categoryStyle(txn.category, income); return ( -
- - {txn.title || txn.category} - - - {isTransfer ? `${txn.category} · ${s.list.transferTag}` : txn.category} +
+ + {isTransfer ? "🔁" : cat.emoji} +
+ + {txn.title || cat.name} + + + {isTransfer ? `${cat.name} · ${s.list.transferTag}` : cat.name} + +
{amountText} @@ -125,7 +144,14 @@ export function TransactionList() { return { income, expense }; }, [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]); return ( diff --git a/src/features/home/buildHome.ts b/src/features/home/buildHome.ts index 940aa8f..8596591 100644 --- a/src/features/home/buildHome.ts +++ b/src/features/home/buildHome.ts @@ -2,6 +2,7 @@ import type { NetWorth } from "../../api/schemas/networth"; import type { Analyze } from "../../api/schemas/analyze"; import type { Budget } from "../../api/schemas/budget"; import { tugrik, tugrikRaw } from "../../ds/money"; +import { categoryName } from "../../ds/categoryStyle"; import { sample } from "./sample"; /** 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)) .slice(0, 3) .map((row) => ({ - name: row.category, + name: categoryName(row.category), spent: dec(row.spent), range: tugrikRaw(dec(row.limit)), })); diff --git a/src/features/planner/PlannerView.tsx b/src/features/planner/PlannerView.tsx index d8f0608..e33842f 100644 --- a/src/features/planner/PlannerView.tsx +++ b/src/features/planner/PlannerView.tsx @@ -22,6 +22,7 @@ import { Skeleton, } from "@seed-design/react"; import { Card, MercuryButton, HideAmountsToggle, NameEdit } from "@/ds"; +import { categoryStyle } from "@/ds/categoryStyle"; import { tugrik, tugrikShort } from "@/ds/money"; import { useBudget, useNetWorth } from "@/api/hooks/reads"; import { usePutBudget, useSavingsGoalMutations } from "@/api/hooks/mutations"; @@ -478,7 +479,10 @@ function CategoryList({ style={{ color: "inherit", textDecoration: "none" }} >
- {row.category} + + {categoryStyle(row.category).emoji} + {categoryStyle(row.category).name} + {tugrik(spent)} {limit > 0 ? ` / ${tugrik(limit)}` : " / —"}