Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
80 changes: 68 additions & 12 deletions apps/web/components/features/check/CheckScreen.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
"use client";

import { useState } from "react";
import { useState, useEffect } from "react";

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

Import useRef to help track the last auto-detected category and prevent overriding manual user selections during typing.

Suggested change
import { useState, useEffect } from "react";
import { useState, useEffect, useRef } from "react";

import { NumPad } from "./NumPad";
import { CategoryPicker } from "./CategoryPicker";
import { VerdictOverlay } from "./VerdictOverlay";
Expand All @@ -12,6 +12,45 @@ import type { CheckResponse, Verdict } from "@/types";
import { CATEGORIES } from "@/lib/constants";
import type { CategoryId } from "@/lib/constants";

const MERCHANT_CATEGORY_RULES: [RegExp, CategoryId][] = [
[/grab(food|express|car)?|foodpanda|airasia\s*food|beepit|shopeefood/i, "food"],
[/mcdonald|kfc|burger\s*king|subway|pizza\s*hut|domino|starbucks|tealive|chatime|gong\s*cha|old\s*town|nando|secret\s*recipe|marrybrown|texas\s*chicken|a&w|jollibee|popeyes|rotiboy|breadtalk/i, "food"],
[/tesco|mydin|giant|aeon|jaya\s*grocer|village\s*grocer|cold\s*storage|lotus|99\s*speedmart|kk\s*mart|econsave|guardian|watson|caring/i, "food"],
[/shopee|lazada|zalora|shein|taobao|amazon|ikea|mr\s*diy|daiso|miniso|uniqlo|h&m|zara|cotton\s*on/i, "shopping"],
[/grab(car|taxi|bike)?|mrt|lrt|ktm|rapidkl|bus|parking|petronas|shell|caltex|bpetrol|myeg\s*toll|plus\s*toll/i, "transport"],
[/tng\s*ewallet|touch\s*n\s*go|boost|grabpay|maybank\s*pay|duit\s*now|fpx|transfer/i, "utilities"],
[/celcom|maxis|digi|unifi|astro|time\s*fibre|yes\s*4g|streamyx/i, "utilities"],
[/tnb|syabas|indah\s*water|gas\s*malaysia|sewa|rental/i, "utilities"],
Comment on lines +15 to +23

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

There are multiple regex matching and ordering issues with the current rules:

  1. Greedy Matching: The first rule /grab(food|express|car)?/i matches any string containing "grab" because the suffix group is optional and there are no word boundaries. This means "grabcar", "grabtaxi", and even "grabpay" will match the first rule and be incorrectly categorized as "food".
  2. Contradiction with Test Plan: The test plan states that typing "Grab" should auto-select "Transport". However, because "Grab" matches the first rule (where the suffix is optional), it currently auto-selects "food".

To fix this, make the Grab food/express suffixes non-optional so "grab" alone doesn't match them, place the more specific "grabpay" (utilities) rule before the general "grab" (transport) rule, and use a transport rule where the suffix is optional so "grab" and "grabcar" default to "transport".

Suggested change
const MERCHANT_CATEGORY_RULES: [RegExp, CategoryId][] = [
[/grab(food|express|car)?|foodpanda|airasia\s*food|beepit|shopeefood/i, "food"],
[/mcdonald|kfc|burger\s*king|subway|pizza\s*hut|domino|starbucks|tealive|chatime|gong\s*cha|old\s*town|nando|secret\s*recipe|marrybrown|texas\s*chicken|a&w|jollibee|popeyes|rotiboy|breadtalk/i, "food"],
[/tesco|mydin|giant|aeon|jaya\s*grocer|village\s*grocer|cold\s*storage|lotus|99\s*speedmart|kk\s*mart|econsave|guardian|watson|caring/i, "food"],
[/shopee|lazada|zalora|shein|taobao|amazon|ikea|mr\s*diy|daiso|miniso|uniqlo|h&m|zara|cotton\s*on/i, "shopping"],
[/grab(car|taxi|bike)?|mrt|lrt|ktm|rapidkl|bus|parking|petronas|shell|caltex|bpetrol|myeg\s*toll|plus\s*toll/i, "transport"],
[/tng\s*ewallet|touch\s*n\s*go|boost|grabpay|maybank\s*pay|duit\s*now|fpx|transfer/i, "utilities"],
[/celcom|maxis|digi|unifi|astro|time\s*fibre|yes\s*4g|streamyx/i, "utilities"],
[/tnb|syabas|indah\s*water|gas\s*malaysia|sewa|rental/i, "utilities"],
const MERCHANT_CATEGORY_RULES: [RegExp, CategoryId][] = [
[/grab\s*(food|express)|foodpanda|airasia\s*food|beepit|shopeefood/i, "food"],
[/mcdonald|kfc|burger\s*king|subway|pizza\s*hut|domino|starbucks|tealive|chatime|gong\s*cha|old\s*town|nando|secret\s*recipe|marrybrown|texas\s*chicken|a&w|jollibee|popeyes|rotiboy|breadtalk/i, "food"],
[/tesco|mydin|giant|aeon|jaya\s*grocer|village\s*grocer|cold\s*storage|lotus|99\s*speedmart|kk\s*mart|econsave|guardian|watson|caring/i, "food"],
[/grab\s*pay|tng\s*ewallet|touch\s*n\s*go|boost|maybank\s*pay|duit\s*now|fpx|transfer/i, "utilities"],
[/shopee|lazada|zalora|shein|taobao|amazon|ikea|mr\s*diy|daiso|miniso|uniqlo|h&m|zara|cotton\s*on/i, "shopping"],
[/grab\s*(car|taxi|bike)?|mrt|lrt|ktm|rapidkl|bus|parking|petronas|shell|caltex|bpetrol|myeg\s*toll|plus\s*toll/i, "transport"],
[/celcom|maxis|digi|unifi|astro|time\s*fibre|yes\s*4g|streamyx/i, "utilities"],
[/tnb|syabas|indah\s*water|gas\s*malaysia|sewa|rental/i, "utilities"],

[/clinic|hospital|pharmacy|klinik|farmasi|guardian\s*health|watson\s*health|dentist|doctor|ubat/i, "health"],
[/movie|cinema|gsc|tgv|mbo|netflix|spotify|steam|playstation|xbox|gaming/i, "entertainment"],
[/tutor|class|course|udemy|coursera|school|university|kolej|buku|book\s*store/i, "education"],
];

function autoDetectCategory(merchantName: string): CategoryId | null {
for (const [pattern, cat] of MERCHANT_CATEGORY_RULES) {
if (pattern.test(merchantName)) return cat;
}
return null;
}

function getTimeBasedDefault(): CategoryId {
const hour = new Date().getHours();
if ((hour >= 7 && hour < 10) || (hour >= 12 && hour < 14) || (hour >= 18 && hour < 21)) {
return "food";
}
return "shopping";
}

function formatAmount(raw: string): string {
if (!raw || raw === "0") return "0";
const num = parseFloat(raw);
if (isNaN(num)) return raw;
return new Intl.NumberFormat("en-MY", {
minimumFractionDigits: 0,
maximumFractionDigits: 2,
}).format(num);
}
Comment on lines +44 to +52

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

The current implementation of formatAmount parses the raw string using parseFloat and formats it using Intl.NumberFormat. This introduces a frustrating user experience when typing decimal values:

  1. If the user types a decimal point (e.g., "1500."), parseFloat ignores the trailing dot, and the formatted output displays as "1,500". The user cannot see the decimal point they just typed.
  2. If the user types a trailing zero after a decimal point (e.g., "1500.0"), parseFloat simplifies it to 1500, displaying "1,500". The user cannot see the zero they just typed.

To fix this, split the string by the decimal point, format only the integer part, and then append the decimal part (limited to 2 decimal places for currency) if a decimal point is present.

function formatAmount(raw: string): string {
  if (!raw || raw === "0") return "0";
  const parts = raw.split(".");
  const integerPart = parts[0];
  const decimalPart = parts[1];

  const num = parseFloat(integerPart);
  if (isNaN(num)) return raw;

  const formattedInteger = new Intl.NumberFormat("en-MY", {
    minimumFractionDigits: 0,
    maximumFractionDigits: 0,
  }).format(num);

  if (raw.includes(".")) {
    const slicedDecimal = decimalPart !== undefined ? decimalPart.slice(0, 2) : "";
    return `${formattedInteger}.${slicedDecimal}`;
  }
  return formattedInteger;
}


type Step = "amount" | "category" | "result";

const SARAH_DEMO = {
Expand Down Expand Up @@ -106,13 +145,21 @@ interface CheckScreenProps {
export function CheckScreen({ isSarahDemo = false }: CheckScreenProps) {
const [step, setStep] = useState<Step>("amount");
const [amount, setAmount] = useState(isSarahDemo ? SARAH_DEMO.amount : "");
const [category, setCategory] = useState<CategoryId>(isSarahDemo ? SARAH_DEMO.category : "shopping");
const [category, setCategory] = useState<CategoryId>(isSarahDemo ? SARAH_DEMO.category : getTimeBasedDefault());
const [merchant, setMerchant] = useState(isSarahDemo ? SARAH_DEMO.merchant : "");
const [categoryAutoSet, setCategoryAutoSet] = useState(false);
const [result, setResult] = useState<CheckResponse | null>(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const { success: toastSuccess, error: toastError } = useToast();
Comment on lines +148 to 154

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

In Next.js, components are pre-rendered on the server (SSR) and then hydrated on the client. Since getTimeBasedDefault() uses new Date().getHours(), it will evaluate to the server's system time during SSR and the user's local time during client hydration. If these times differ (e.g., due to timezone differences), it will cause a React hydration mismatch warning and potential UI glitches.

To fix this, initialize the category state to a static default (e.g., "shopping") and update it to the time-based default inside a useEffect hook that runs only on the client after mounting.

  const [category, setCategory] = useState<CategoryId>(isSarahDemo ? SARAH_DEMO.category : "shopping");
  const [merchant, setMerchant] = useState(isSarahDemo ? SARAH_DEMO.merchant : "");
  const [categoryAutoSet, setCategoryAutoSet] = useState(false);
  const [result, setResult] = useState<CheckResponse | null>(null);
  const [loading, setLoading] = useState(false);
  const [error, setError] = useState<string | null>(null);
  const { success: toastSuccess, error: toastError } = useToast();

  useEffect(() => {
    if (!isSarahDemo) {
      setCategory(getTimeBasedDefault());
    }
  }, [isSarahDemo]);


useEffect(() => {
if (!merchant.trim()) { setCategoryAutoSet(false); return; }
const detected = autoDetectCategory(merchant);
if (detected) { setCategory(detected); setCategoryAutoSet(true); }
else setCategoryAutoSet(false);
}, [merchant]);
Comment on lines +156 to +161

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

Currently, when a user types a merchant name (e.g., "Grab"), the category is auto-detected and categoryAutoSet is set to true. If the user manually overrides the category (e.g., to "shopping"), categoryAutoSet is set to false.
However, if the user continues typing (e.g., adding a space or another character to make it "Grab "), the useEffect triggers again because merchant changed. It detects the same category ("food" or "transport") and overrides the user's manual selection.

To respect the user's manual override, track the last auto-detected category using a useRef. If the newly detected category is the same as the last auto-detected category, and the user has manually changed it (i.e., categoryAutoSet is false), do not overwrite their manual choice.

Suggested change
useEffect(() => {
if (!merchant.trim()) { setCategoryAutoSet(false); return; }
const detected = autoDetectCategory(merchant);
if (detected) { setCategory(detected); setCategoryAutoSet(true); }
else setCategoryAutoSet(false);
}, [merchant]);
const lastDetectedRef = useRef<CategoryId | null>(null);
useEffect(() => {
if (!merchant.trim()) {
setCategoryAutoSet(false);
lastDetectedRef.current = null;
return;
}
const detected = autoDetectCategory(merchant);
if (detected) {
if (categoryAutoSet || detected !== lastDetectedRef.current) {
setCategory(detected);
setCategoryAutoSet(true);
lastDetectedRef.current = detected;
}
} else {
setCategoryAutoSet(false);
lastDetectedRef.current = null;
}
}, [merchant, categoryAutoSet]);


function onVoiceParsed(parsed: { amount: string; merchant: string; category: string }) {
if (parsed.amount) setAmount(parsed.amount);
if (parsed.merchant) setMerchant(parsed.merchant);
Expand Down Expand Up @@ -160,6 +207,8 @@ export function CheckScreen({ isSarahDemo = false }: CheckScreenProps) {
setStep("amount");
setAmount("");
setMerchant("");
setCategory(getTimeBasedDefault());
setCategoryAutoSet(false);
setResult(null);
setError(null);
}
Expand Down Expand Up @@ -194,22 +243,29 @@ export function CheckScreen({ isSarahDemo = false }: CheckScreenProps) {
<div className="text-center" data-testid="amount-display">
<p className="font-sans text-sm font-bold text-muted mb-1">How much are you spending?</p>
<div className="font-headline text-5xl font-bold text-foreground tracking-tight">
RM{amount || "0"}
RM {formatAmount(amount || "0")}
</div>
</div>

{/* Merchant input */}
<input
type="text"
placeholder="Where? (e.g. Shopee, McDonald's)"
value={merchant}
onChange={(e) => setMerchant(e.target.value)}
data-testid="merchant-input"
className="w-full rounded-xl border border-border bg-white px-4 py-3 font-sans text-sm text-foreground placeholder:text-muted focus:outline-none focus:ring-2 focus:ring-primary shadow-sm"
/>
<div className="relative">
<input
type="text"
placeholder="Where? (e.g. Shopee, McDonald's)"
value={merchant}
onChange={(e) => setMerchant(e.target.value)}
data-testid="merchant-input"
className="w-full rounded-xl border border-border bg-white px-4 py-3 font-sans text-sm text-foreground placeholder:text-muted focus:outline-none focus:ring-2 focus:ring-primary shadow-sm"
/>
{categoryAutoSet && (
<span className="absolute right-3 top-1/2 -translate-y-1/2 rounded-full bg-primary/10 px-2 py-0.5 text-xs font-semibold text-primary">
auto
</span>
)}
</div>

{/* Category picker */}
<CategoryPicker selected={category} onChange={setCategory} />
<CategoryPicker selected={category} onChange={(c) => { setCategory(c); setCategoryAutoSet(false); }} />

{/* Budget impact preview */}
{amount && parseFloat(amount) > 0 && (
Expand Down
29 changes: 29 additions & 0 deletions apps/web/public/manifest.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
{
"name": "Bajet Buddy",
"short_name": "Bajet",
"description": "Smart budgeting for Malaysians — check your spend before you regret it.",
"start_url": "/dashboard",
"display": "standalone",
"background_color": "#fff9f4",
"theme_color": "#BA6200",
"icons": [
{ "src": "/icon-192.png", "sizes": "192x192", "type": "image/png" },
{ "src": "/icon-512.png", "sizes": "512x512", "type": "image/png" }
],
"shortcuts": [
{
"name": "Check Spend",
"short_name": "Check",
"description": "Quick-check a purchase before you make it",
"url": "/check",
"icons": [{ "src": "/icon-192.png", "sizes": "192x192" }]
},
{
"name": "Scan Receipt",
"short_name": "Scan",
"description": "Scan a receipt to log a transaction",
"url": "/receipts",
"icons": [{ "src": "/icon-192.png", "sizes": "192x192" }]
}
]
}
2 changes: 1 addition & 1 deletion apps/web/tsconfig.tsbuildinfo

Large diffs are not rendered by default.

Loading