feat: smart check screen UX + PWA manifest#26
Conversation
- Auto-categorize merchant input via regex patterns (Grab→transport, Shopee→shopping, etc.) with "auto" pill indicator - Time-based category default: food during meal hours, shopping otherwise - Format amount display with Intl.NumberFormat thousands separator (RM 1,000) - Add PWA manifest.json with shortcuts to Check Spend and Scan Receipt - Resetting the form restores time-based default category https://claude.ai/code/session_017cLXjZu6kkjXRf2BemMPpp
❌ Deploy Preview for bajet-buddy failed.
|
Deploying with
|
| Status | Name | Latest Commit | Updated (UTC) |
|---|---|---|---|
| ❌ Deployment failed View logs |
bajet-buddy | 9c80c34 | May 28 2026, 02:55 AM |
There was a problem hiding this comment.
Code Review
This pull request introduces automatic merchant category detection and time-based defaults to the CheckScreen component, along with a web app manifest for "Bajet Buddy". The code review highlights several critical issues: greedy regex matching causing incorrect category assignments (e.g., Grab matching food instead of transport), a potential Next.js SSR hydration mismatch from using local time during state initialization, a frustrating UX in formatAmount that strips trailing decimal points and zeros while typing, and a bug where continued typing overrides manual category selections. Suggestions are provided to resolve these issues using refined regex patterns, client-side mounting effects, and useRef tracking.
| 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"], |
There was a problem hiding this comment.
There are multiple regex matching and ordering issues with the current rules:
- Greedy Matching: The first rule
/grab(food|express|car)?/imatches 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". - 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".
| 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"], |
| 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(); |
There was a problem hiding this comment.
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]);
| "use client"; | ||
|
|
||
| import { useState } from "react"; | ||
| import { useState, useEffect } from "react"; |
| 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); | ||
| } |
There was a problem hiding this comment.
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:
- If the user types a decimal point (e.g.,
"1500."),parseFloatignores the trailing dot, and the formatted output displays as"1,500". The user cannot see the decimal point they just typed. - If the user types a trailing zero after a decimal point (e.g.,
"1500.0"),parseFloatsimplifies it to1500, 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;
}
| useEffect(() => { | ||
| if (!merchant.trim()) { setCategoryAutoSet(false); return; } | ||
| const detected = autoDetectCategory(merchant); | ||
| if (detected) { setCategory(detected); setCategoryAutoSet(true); } | ||
| else setCategoryAutoSet(false); | ||
| }, [merchant]); |
There was a problem hiding this comment.
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.
| 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]); |
Summary
foodduring meal hours (7–10am, 12–2pm, 6–9pm) andshoppingotherwise — zero friction for the most common use cases.Intl.NumberFormat—RM 1,000instead ofRM1000./public/manifest.jsonwithshortcutsfor "Check Spend" (/check) and "Scan Receipt" (/receipts) so users who install the app to their home screen get quick-add entry points.Test plan
RM 1,500https://claude.ai/code/session_017cLXjZu6kkjXRf2BemMPpp
Generated by Claude Code
Summary by cubic
Adds smart category detection and time-based defaults to the Check screen, plus RM amount formatting. Includes a PWA
manifest.jsonwith shortcuts for/checkand/receipts.Intl.NumberFormat(e.g. RM 1,500).public/manifest.jsonwith icons andshortcuts: "Check Spend" (/check) and "Scan Receipt" (/receipts) for home-screen installs.Written for commit 9c80c34. Summary will update on new commits.
Review in cubic