# Keyword rules used to tag App Store reviews in "The State of Meal Planning Apps 2026" # https://getmealpot.com/research/meal-planning-apps-2026/ (MealPot, November 2026) # Python regular expressions, applied to the lowercased review title + body. One review can match several themes. # Built after reading about 300 sampled reviews; precision was spot-checked on 5-7 matches per theme (see the methodology). # Theme taxonomy: keyword rules (applied to lowercased title + body). # Built after reading ~300 sampled reviews across star bands and app types. import re def R(*pats): return re.compile('|'.join(pats), re.I) THEMES = { # --- money --- 'paywall_no_free_tier': R(r"not (actually |really )?free", r"no free (version|option|tier|features)", r"paywall", r"pay ?wall", r"(have|need|forced?|required) to (pay|subscribe|start (a|the) (free )?trial|sign up for (a|the) (free )?trial)", r"can'?t (do|use|see|access) (anything|a single thing|much|it)[^.]{0,30}(without|unless)[^.]{0,20}(pay|subscri|trial|premium)", r"(everything|most (things|features|recipes)|all the (good )?features) (is |are )?(locked|behind)", r"locked behind", r"without (paying|a subscription|subscribing)", r"advertis\w* (as|it'?s) free", r"says (it'?s )?free", r"free (version|plan) (is )?(useless|worthless|very limited|so limited|isn'?t worth)", r"no (way|option) (to skip|around the pay)"), 'price_too_high': R(r"too expensive", r"overpriced", r"over ?priced", r"\bpricey\b", r"(costs?|price) (is |way )?too (much|high)", r"not worth (the|paying|it|\$|£)", r"(\$|£|€)\s?\d+(\.\d\d)? ?(a|per|/) ?(week|wk|month|mo|year|yr)", r"\bexpensive\b", r"can'?t afford", r"price (increase|hike|went up|jump)", r"raised (the|their) price"), 'billing_trial_refund': R(r"\brefund", r"charged me", r"got charged", r"been charged", r"was charged", r"\bcharged (twice|again|for|after|even)", r"auto[- ]?renew", r"unauthori[sz]ed (charge|payment)", r"(trial)[^.]{0,60}(charg|bill)", r"(charg|bill)[^.]{0,60}(trial)", r"(can'?t|couldn'?t|unable to|impossible to|won'?t let me) cancel", r"after (i )?cancel+ed", r"\bscam\b", r"double charged", r"billed (me )?(again|twice|after)"), # --- content --- 'recipe_variety_repetition': R(r"same (meals?|recipes?|few|handful|things|dishes|stuff|options)", r"repetitive", r"\brepeat(s|ing|ed)? (the )?(same|meals|recipes)", r"(limited|small|tiny) (number of |amount of |selection of |pool of |choice of |variety of )?(recipes|meals|options|choices|selection|database|library|pool|variety)", r"(not|aren'?t|isn'?t) (enough|many) (recipes|meals|options|variety|choices)", r"lack(s|ing)? (of )?variety", r"more (recipes|variety|options|meal options)", r"recipes are (very |so |really )?limited", r"options are (very |so |really )?limited", r"over and over"), 'recipe_quality_taste': R(r"\bbland\b", r"flavou?rless", r"tasteless", r"no flavou?r", r"(recipes|meals|food) (are|were|was|is) (gross|awful|terrible|bad|disgusting|weird|strange|inedible|boring|unappetizing)", r"not real meals", r"didn'?t turn out", r"\bdelicious\b", r"\btasty (recipes|meals|food)", r"(recipes|meals|food) (are|were|is) (great|amazing|delicious|fantastic|excellent|good|tasty|yummy)", r"great recipes", r"good recipes", r"amazing recipes", r"(well|kitchen)[- ]tested", r"turn(s|ed)? out (great|perfect|perfectly|amazing)", r"quality (of the )?recipes|recipe quality"), 'inspiration_new_ideas': R(r"inspiration", r"new (recipes|meals|ideas|dishes|things)", r"meal ideas", r"\bideas\b", r"try(ing)? new", r"out of a rut|stuck in a rut|\brut\b", r"same (old )?(boring )?(meals|things|dinners)[^.]{0,30}(every|all the time|over)"), 'dietary_allergy': R(r"\bvegan", r"vegetarian", r"pescatarian", r"gluten", r"celiac|coeliac", r"allerg", r"dairy[- ]free", r"lactose", r"\bketo\b", r"low[- ]?carb", r"fodmap", r"whole ?30", r"paleo", r"halal", r"kosher", r"diabet", r"dietary (restriction|need|requirement|preference)", r"intoleran", r"nut[- ]free", r"\bplant[- ]based", r"mediterranean diet"), 'picky_kids_family': R(r"picky", r"fussy", r"\bkids?\b", r"\bchildren\b", r"toddler", r"family of \d|family of (four|five|six|three)", r"teenage", r"\bmy (son|daughter)\b", r"feed(ing)? (my|a) family", r"family meals|family dinners|family friendly"), 'preferences_ignored': R(r"(doesn'?t|does not|didn'?t|won'?t) (listen|follow|respect|take into account|honou?r)", r"ignor(es|ed|ing) (my|the) (preferences|settings|restrictions|allergies|dislikes|budget|filters)", r"even though i (said|selected|chose|put|told|specified|asked)", r"despite (me )?(saying|selecting|choosing|telling|specifying)", r"still (shows|gives|suggests|includes|recommends)[^.]{0,40}(meat|fish|pork|dairy|gluten|nuts|i (don'?t|can'?t))", r"(when|but) i (said|selected|asked for|put) (no|vegetarian|vegan|low)"), # --- core workflow --- 'grocery_list_quality': R(r"(shopping|grocery) list[^.]{0,80}(combin|consolidat|duplicat|merge|aisle|categor|sort|unit|quantit|amount|organi[sz]|messy|wrong|doesn'?t add|adds up)", r"(combin|consolidat|duplicat|merg)\w*[^.]{0,40}(ingredients|items)[^.]{0,30}(list)?", r"\baisles?\b", r"(sorted|organi[sz]ed|grouped) by (aisle|category|store section|department)"), 'grocery_list_generic': R(r"(shopping|grocery) lists?", r"\bshopping\b"), 'store_integration_delivery': R(r"walmart", r"kroger", r"instacart", r"\btesco", r"\baldi\b", r"sainsbury", r"\basda\b", r"\bocado", r"\bheb\b|h-e-b", r"\btarget\b", r"\bshipt\b", r"amazon fresh", r"whole foods", r"(send|sends|export|add|push)\w* (it |the list |my list |ingredients )?(directly )?to (my |your )?(cart|store|grocery|walmart|instacart|kroger)", r"(store|grocery) (pickup|pick-up|pick up|delivery)", r"\bcurbside\b", r"store prices|prices at (the|my) store|price at store|local store"), 'budget_money_saving': R(r"\bbudget", r"sav(e|es|ed|ing) (me |us |so much |a lot of |lots of )?money", r"spend(ing)? less", r"cheap(er)? (meals|recipes|groceries|ingredients)", r"grocery (bill|costs?|spend)", r"(cut|reduce|lower)\w* (my |our )?(grocery|food) (bill|costs?|spend)", r"(cost|price)s? per (serving|meal|portion)", r"(eating|eat) out less|less takeout|fewer takeaways|takeaway|takeout"), 'time_mental_load': R(r"sav(e|es|ed|ing) (me |us )?(so much |a lot of |tons of )?time", r"time[- ]saver", r"what'?s for (dinner|tea|supper)", r"what to (eat|cook|make|have for)", r"(don'?t|do not|no longer) have to think", r"(less|reduc\w*|no more|without the) stress", r"stressful", r"mental (load|energy|space)", r"decision fatigue", r"\bbusy\b", r"\boverwhelm"), 'import_recipes': R(r"\bimport", r"(save|saving|grab|pull|add)\w* (recipes? )?from (any|instagram|tiktok|pinterest|facebook|youtube|websites?|blogs?|the web|online|social|links?)", r"\binstagram\b", r"\btiktok\b[^.]{0,40}recipe|recipe[^.]{0,40}\btiktok\b", r"\bpinterest\b", r"share (sheet|extension)", r"paste (a |the )?(link|url)", r"\burl\b", r"(any|every) (website|site|blog)", r"scan(ning)? (a |my )?(cookbook|recipe card|recipes)"), 'nutrition_macros_weight': R(r"calori", r"\bmacros?\b", r"\bprotein\b", r"nutrition", r"nutrient", r"weight ?loss", r"lose weight|losing weight|lost \d+ ?(lbs|pounds|kg|stone)", r"\bcut(ting)? phase|\bbulk(ing)?\b", r"\bkcal\b"), 'leftovers_waste_batch': R(r"leftover", r"food waste", r"wast(e|ed|ing) (less |so much |less |)?(food|ingredients|produce|groceries)", r"(throw|throwing|threw) (away|out)", r"go(es|ing)? (off|bad)|went (off|bad)|spoil", r"use (up|what i have)", r"batch[- ]?cook", r"meal[- ]?prep", r"overlapping ingredients|share ingredients|same ingredients across|reuse ingredients|using the same ingredients", r"half (a|an) (bag|bunch|can|jar|packet|tin)"), 'pantry_inventory': R(r"\bpantry\b", r"what i (already )?have( on hand| in (the|my) (fridge|cupboard|kitchen))?", r"ingredients (i|you) (already )?have", r"\bfridge\b", r"\bcupboards?\b", r"expir", r"inventory"), 'servings_scaling_units': R(r"\bservings?\b", r"\bscal(e|ing)\b", r"\bportions?\b", r"\bmetric\b", r"\bimperial\b", r"\bgrams\b", r"\bounces\b|\boz\b", r"convert", r"conversion", r"\bunits?\b", r"\bmeasurements?\b", r"number of people|how many people"), 'planning_calendar_flex': R(r"\bcalendar\b", r"drag (and|&) drop", r"plan ahead", r"move (a |the )?meals?", r"swap(ping)?", r"(weekly|meal) plan(ner)?", r"\bschedule\b", r"plan (my|our|the) (week|meals)", r"breakfast,? lunch"), 'household_sharing': R(r"share (it |the list |lists |my list |recipes )?with (my )?(husband|wife|partner|spouse|family|roommate|boyfriend|girlfriend|kids|household|mom|mum)", r"(shared?|sharing) (shopping |grocery )?lists?", r"(husband|wife|partner|spouse|hubby|boyfriend|girlfriend) (and i|can|also|uses|adds|sees)", r"family (plan|sharing|account|subscription)", r"\btwo (accounts|phones|devices)\b", r"multiple (users|devices|people)", r"sync(s|ing|ed)? (between|across|with) (our|my|both|multiple|all)"), 'cooking_mode_steps': R(r"cook(ing)? mode", r"step[- ]by[- ]step", r"screen (awake|on|off|dims|locks|lock|goes dark|turns off|sleep)", r"\btimers?\b", r"instructions (are|were) (unclear|confusing|vague|missing|wrong|easy|clear|simple)", r"(easy|clear|simple) (to follow|instructions|directions)", r"\bsteps\b"), 'bugs_crashes': R(r"\bcrash", r"\bbug(s|gy)?\b", r"glitch", r"freez", r"won'?t (load|open|work|sync|let me)", r"doesn'?t (load|open|work)", r"(not|stopped) (loading|working|opening)", r"keeps? (crashing|freezing|loading|closing|logging)", r"\bbroken\b", r"stuck (on|at|in)", r"loading (screen|forever|wheel|circle)", r"spinning", r"blank (screen|page)", r"error"), 'data_loss_login_sync': R(r"lost (all )?(of )?(my|our) (recipes|data|lists?|plans?|saved|collection|account)", r"(recipes|data|lists?|everything) (are |is |were |was )?(gone|disappeared|vanished|deleted|lost)", r"deleted (all )?(of )?my (recipes|lists?|data)", r"(can'?t|cannot|couldn'?t|unable to|won'?t let me) (log ?in|sign ?in|login)", r"log(ged|s)? (me )?out", r"\bsync(ing|s|ed)?\b", r"new (phone|device)"), 'ads': R(r"\bads\b", r"\badverts?\b", r"advertisements?", r"commercials", r"pop[- ]?ups?", r"ad[- ]free|no ads"), 'ai_mentions': R(r"\bai\b", r"a\.i\.", r"artificial intelligence", r"chat ?gpt", r"\bgpt\b", r"generative", r"ai[- ]generated", r"\bllm\b"), 'customer_support': R(r"customer (service|support|care)", r"\bsupport (team|staff)?\b", r"(no|zero|never got a|never received a|without) (response|reply|answer)", r"emailed", r"contacted", r"(ignored|ignore) (my|all|me)", r"(responsive|helpful|quick|prompt|fast)[^.]{0,20}(support|service|response|reply)", r"(support|service|developer)[^.]{0,30}(responsive|helpful|quick|prompt|fast|kind|amazing|great)"), 'onboarding_setup': R(r"onboarding", r"(set ?up|sign ?up|signup|registration) (process|questions|took|screens|was)", r"(so many|lots of|all the|all those|endless|long|a bunch of) questions", r"\bquiz\b", r"questionnaire", r"(answer|answered|answering) (all )?(the |these |those |a bunch of |lots of )?questions", r"before (you|i) (can|could|even) (see|use|try)"), 'ease_of_use_ui': R(r"easy to use", r"easy to navigate", r"user[- ]friendly", r"intuitive", r"simple to use", r"\bclunky\b", r"confusing", r"hard to (use|navigate|find|figure)", r"not (very )?user", r"\bcluttered\b", r"(beautiful|clean|nice|great) (design|interface|ui|layout)"), 'update_regression': R(r"(new|latest|recent|last) update", r"since the (last |latest |new |recent )?update", r"after (the )?(last |latest |recent )?update", r"used to (be|love|work|have|like)", r"(removed|took away|taking away) (a |the )?(feature|features|ability|option)", r"(went|gone|going) downhill", r"redesign"), 'platform_devices': R(r"\bipad\b", r"\bmac\b|macbook|macos", r"\bandroid\b", r"\bdesktop\b", r"\bweb (version|app|site)\b|\bwebsite\b|browser", r"apple watch|\bwatch\b app", r"\bwidgets?\b", r"dark mode", r"text size|larger text|font size", r"\balexa\b|echo show|siri"), 'false_advertising': R(r"false(ly)? advertis", r"misleading", r"bait and switch|bait-and-switch", r"(tiktok|instagram|facebook) ad", r"saw (it|this|an ad|the ad|ads?)[^.]{0,20}(on )?(tiktok|instagram|facebook|social)", r"\bdeceptive\b", r"\blied\b|\blies\b"), 'delivery_logistics': R(r"\bshoppers?\b", r"substitut", r"\bdriver\b", r"missing (items|ingredients)", r"(late|delayed|wrong) (delivery|order|items?)", r"\bdelivered\b", r"\bbox(es)?\b", r"\bdamaged\b", r"\bproduce\b"), 'accessibility': R(r"accessib", r"dyslexi", r"\badhd\b", r"autis", r"vision|visually impaired|eyesight", r"larger text|text size|font size|bigger font", r"disabilit|disabled"), } # Themes that are mainly about the service, not the planning product SERVICE_THEMES = {'delivery_logistics'} # Churn / switching language CHURN = { 'deleted_uninstalled': R(r"\bdelet(ed|ing|e) (the|this|it|app)", r"uninstall", r"deleted it", r"deleting (it|this)", r"(will|gonna|going to) delete"), 'cancelled': R(r"cancel+(ed|ing) (my |the |our )?(subscription|membership|trial|account|plan)|i cancel+ed|we cancel+ed|cancel+ed (it|immediately|right away|before)"), 'switched_went_back': R(r"switch(ed|ing)? (over )?(to|back)", r"went back to", r"going back to", r"go back to (using )?", r"moving (over )?to", r"moved (over )?to", r"back to (using )?(my )?(notes|paper|pen|spreadsheet|google|pinterest)", r"use (\w+ )?instead", r"found (a )?(better|another) (app|one|option)"), 'used_to_love': R(r"used to (love|be (great|good|amazing|my favou?rite|the best))", r"loved (this|it) (until|before|but)", r"(been|was) a (loyal|long[- ]?time) (user|customer|subscriber)"), 'diy_alternative': R(r"chat ?gpt", r"spreadsheet|excel|google sheets?", r"notes app|apple notes|my notes", r"pen and paper|paper and pen|notebook|on paper|paper list", r"\bpinterest\b", r"google (it|recipes|search)|just google", r"\btiktok\b (is|has|videos are) (better|free)|just go to tiktok", r"(other|many|plenty of) (other )?free (apps|ai|options|alternatives)"), } WISH = R(r"\bi wish\b", r"would be (nice|great|awesome|helpful|amazing|lovely|cool|good|useful|perfect|ideal|better)", r"would love", r"\bplease add\b", r"\bplease (make|let|allow|bring|include|give|consider|fix|update|implement|have)\b", r"\bif only\b", r"feature request", r"\bi'?d love\b", r"\bi would like\b", r"\bit needs\b|\bneeds (a|an|to|more|the ability|option)\b", r"(hope|hoping) (you|they|the dev)\w* (will |can |could )?(add|make|include|bring|implement)", r"should (add|have|include|allow|let)", r"\bwish (it|there|they|you|the app)\b", r"only (thing|complaint|request|suggestion) (i'?d|i would|is|missing)", r"\bmissing\b", r"\bsuggestion\b", r"\bwant(ed)? (a |an |the )?(option|ability|way|feature)") # Wish -> feature buckets (applied to the wish sentence/window) WISH_FEATURES = { 'free tier / cheaper / one-off price': R(r"free (version|tier|option|plan)", r"cheaper", r"lower (the )?price", r"one[- ]time (purchase|payment|fee)", r"lifetime", r"(monthly|weekly) (option|plan|subscription)", r"student (discount|price)", r"family plan|duo|household (plan|subscription)", r"\bprice\b|\bcost\b|afford"), 'more recipes / variety / cuisines': R(r"more (recipes|variety|options|meals|cuisines|choices|dishes|vegetarian|vegan|breakfast|lunch|dinner|snack|dessert|meat)", r"(bigger|larger|wider) (selection|database|variety|range)", r"variety", r"\bcuisines?\b"), 'add/edit own recipes & import': R(r"(add|upload|enter|input|import|save|create) (my |your |our )?own (recipes|meals)", r"\bimport", r"(instagram|tiktok|pinterest|youtube|facebook|website|url|link)", r"edit (the |a )?recipes?", r"custom (recipes|meals)"), 'breakfast/lunch/snacks in plan': R(r"breakfast", r"lunch(es)?", r"snacks?", r"desserts?"), 'dietary filters / exclusions / allergies': R(r"exclude", r"allerg", r"(dislike|won'?t eat|don'?t eat|filter)", r"vegan|vegetarian|gluten|dairy|keto|pescatarian|low ?carb|fodmap|halal|kosher|picky"), 'household sharing / multi-user': R(r"\bshare\b|sharing|shared", r"(husband|wife|partner|spouse|family member|household)", r"(multiple|two|2|more) (users|accounts|people|devices|phones)", r"family (account|plan|sharing)", r"\bsync"), 'grocery list improvements (aisles, combine, units)': R(r"(shopping|grocery) list", r"\baisles?\b", r"categor(y|ies|ise|ize)", r"combin|consolidat|merge", r"check (off|items)"), 'store integration / prices / delivery': R(r"walmart|kroger|instacart|tesco|aldi|sainsbury|asda|ocado|target|publix|safeway|costco|whole foods|\bheb\b|\bstores?\b|\bcart\b|pickup|delivery"), 'nutrition / macros / calories': R(r"calori|macro|protein|nutrition|nutrient|carbs?\b|\bfat\b|sodium|\bfiber\b"), 'servings, scaling & units': R(r"serving|scale|scaling|portion|metric|imperial|grams|ounces|\bcups?\b|convert|conversion|\bunits?\b|measurement|number of people|how many people"), 'meal plan flexibility (calendar, swap, repeat, leftovers)': R(r"calendar|drag|move (meals|recipes)|swap|repeat (meals|a meal|recipes)|same meal (twice|for)|multiple days|leftover|batch|meal ?prep|plan (ahead|for (the|next) week|future)|\bweeks?\b"), 'pantry / use what I have / expiry': R(r"pantry|what i have|on hand|fridge|expir|inventory|use up"), 'platform: iPad / web / Android / Mac / widgets / watch': R(r"ipad|\bmac\b|android|desktop|web (version|app)|website|computer|laptop|widget|apple watch|\bwatch\b|carplay|alexa|echo|siri"), 'dark mode / text size / accessibility': R(r"dark mode|text size|font|larger text|bigger text|accessib"), 'cooking mode / timers / instructions': R(r"cook(ing)? mode|timer|step[- ]by[- ]step|instructions|steps|screen (on|awake)|keep the screen"), 'print / export / share recipes': R(r"\bprint|export|\bpdf\b|share (a |the )?recipes?"), 'organisation: folders, tags, search, bulk edit': R(r"folders?|tags?|categor|collections?|\bsearch\b|\bsort\b|bulk|organi[sz]"), 'nutrition-free: notes/ratings/comments on recipes': R(r"\bnotes?\b|rating|comments?|review"), 'budget / cost per meal / cheaper meals': R(r"budget|cost per|price per|cheap|save money|\bdeals?\b|\bsales?\b|coupon"), } BRANDS = ['mealime','paprika','anylist','plan to eat','samsung food','whisk','recipe keeper','mela','crouton','recime','pepperplate','yummly','allrecipes','nyt','new york times', 'emeals','eat this much','prepear','cooklist','supercook','bigoven','big oven','tasty','hellofresh','hello fresh','mob','mise','tapcook','potto','herbi','whipp','mealia', 'myfitnesspal','my fitness pal','lose it','cronometer','macrofactor','noom','weight watchers','ww app','pinterest','chatgpt','chat gpt','gemini','claude','copilot','notes','spreadsheet', 'google','paper','notebook','instacart','walmart','kroger','tesco','pestle','stashcook','deglaze','umami','cookbook','kitchen stories','sidechef','mealprep pro','mealpreppro','real plans','ollie','platejoy','paprika 3','just the recipe','recipe box','our groceries','ourgroceries','bring','listonic','todoist','reminders','apple reminders','google keep','keep']