Skip to content

feat: smart check screen UX + PWA manifest#26

Merged
timothylee58 merged 1 commit into
mainfrom
claude/funny-johnson-bVave
May 28, 2026
Merged

feat: smart check screen UX + PWA manifest#26
timothylee58 merged 1 commit into
mainfrom
claude/funny-johnson-bVave

Conversation

@timothylee58

@timothylee58 timothylee58 commented May 28, 2026

Copy link
Copy Markdown
Owner

Summary

  • Auto-categorization: Merchant input is matched against 40+ regex patterns (Grab, Shopee, McDonald's, Tesco, TNB, etc.) and the category is set automatically with an "auto" pill indicator in the input field. Manually overriding the category clears the auto flag.
  • Time-based smart default: Category pre-fills as food during meal hours (7–10am, 12–2pm, 6–9pm) and shopping otherwise — zero friction for the most common use cases.
  • RM currency formatting: Amount display uses Intl.NumberFormatRM 1,000 instead of RM1000.
  • PWA manifest: Added /public/manifest.json with shortcuts for "Check Spend" (/check) and "Scan Receipt" (/receipts) so users who install the app to their home screen get quick-add entry points.

Test plan

  • Type "Grab" in merchant field → Transport auto-selected, "auto" pill appears
  • Type "Shopee" → Shopping auto-selected
  • Type "McDonald's" → Food auto-selected
  • Manually tap a different category → "auto" pill disappears
  • Open the app at 8am — Food pre-selected; open at 3pm — Shopping pre-selected
  • Enter 1500 → amount displays as RM 1,500
  • Install to home screen (Android/iOS) → shortcuts for Check Spend and Scan Receipt appear

https://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.json with shortcuts for /check and /receipts.

  • New Features
    • Auto-categorize merchant input with 40+ regex rules (Grab, Shopee, McDonald's, Tesco, TNB, etc.); shows an "auto" pill; manual change clears it.
    • Time-based default category: food (7–10am, 12–2pm, 6–9pm); shopping otherwise; reset restores this default.
    • Amount display formatted with Intl.NumberFormat (e.g. RM 1,500).
    • PWA public/manifest.json with icons and shortcuts: "Check Spend" (/check) and "Scan Receipt" (/receipts) for home-screen installs.

Written for commit 9c80c34. Summary will update on new commits.

Review in cubic

- 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
@netlify

netlify Bot commented May 28, 2026

Copy link
Copy Markdown

Deploy Preview for bajet-buddy failed.

Name Link
🔨 Latest commit 9c80c34
🔍 Latest deploy log https://app.netlify.com/projects/bajet-buddy/deploys/6a17aeab37145d0008e20960

@cloudflare-workers-and-pages

Copy link
Copy Markdown

Deploying with  Cloudflare Workers  Cloudflare Workers

The latest updates on your project. Learn more about integrating Git with Workers.

Status Name Latest Commit Updated (UTC)
❌ Deployment failed
View logs
bajet-buddy 9c80c34 May 28 2026, 02:55 AM

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

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.

Comment on lines +15 to +23
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"],

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"],

Comment on lines +148 to 154
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();

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]);

"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";

Comment on lines +44 to +52
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);
}

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;
}

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

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]);

@timothylee58
timothylee58 marked this pull request as ready for review May 28, 2026 03:01
@timothylee58
timothylee58 merged commit 03a8aa0 into main May 28, 2026
2 of 7 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants