# FunkyJuice Recipes A python application for managing e-juice recipes * Uses SQLite3 as database * Uses Toga as GUI * Uses Peewee ORM * Separate files per model * Separate files per migration * Migrations for database schema changes using `playhouse.migrate` * Migrations are versioned via the date they were created (YYYY-MM-DD_migration_name.py) * Migrations state is stored in a Migrations table in the database * Migrations table does not use a migration file of its own * Migrations are run automatically on startup (check migration files vs database entries in migrations table) * At startup * The app creates (if missing) the per‑user data directory at `app.paths.data` * The app stores the SQLite DB at `/funkyjuicerecipes.data`. * The connection enables pragmas: `{'foreign_keys': 1, 'journal_mode': 'wal', 'synchronous': 1}`. * Tests use a temporary DB path. * Uses class wrappers for Toga widgets * FunkyJuiceRecipesApp(toga.App) * MainWindow(toga.MainWindow) * RecipesTable(toga.Table) * RecipeForm(toga.Box -> with form elements as needed) * AddRecipeButton(toga.Button) * EditRecipeButton(toga.Button) * DeleteRecipeButton(toga.Button -> with confirmation dialog) * FlavorsTable(toga.Table) * AddFlavorButton(toga.Button) * EditFlavorButton(toga.Button) * DeleteFlavorButton(toga.Button -> with confirmation dialog) * FlavorForm(toga.Box -> with form elements as needed) * Uses briefcase to package app for distribution * Code follows PEP8 * Code follows SOLID principles * SRP at method level ## How to Run * `pip install briefcase` * or `uv add briefcase` * `briefcase dev` * or `python -m funkyjuicerecipes.app` ## Flavors Soft Delete Policy * When a flavor is deleted * Flavors are Soft Deleted if they are in a recipe. * Flavors are Hard Deleted if they are not in a recipe. * Flavors can be Un-Deleted when viewing the Soft Deleted Flavors list. * Flavors can be Hard Deleted when viewing the Soft Deleted List and they are no longer in a recipe. * UI Queries default to `WHERE is_deleted = 0` * Hard Delete is only allowed when `NOT EXISTS (SELECT 1 FROM recipe_flavor WHERE flavor_id=flavor.id)` ## Directory/File Structure * project-root/ * pyproject.toml * README.md * src/ * funkyjuicerecipes/ * __init__.py * app.py # Toga App subclass and `main()` * ui/ * __init__.py * buttons/ * __init__.py * add_recipe_button.py * edit_recipe_button.py * delete_recipe_button.py * add_flavor_button.py * edit_flavor_button.py * delete_flavor_button.py * forms/ * __init__.py * recipe_form.py * flavor_form.py * tables/ * __init__.py * recipes_table.py * flavors_table.py * dialogs/ * __init__.py * delete_recipe_confirmation.py * delete_flavor_confirmation.py * data/ * __init__.py * db.py # database init (Peewee SqliteDatabase, pragmas) * models/ * __init__.py * flavor.py * recipe.py * recipe_flavor.py * migrations/ * __init__.py * 2025-12-18_flavor_table_created.py * 2025-12-18_recipe_table_created.py * 2025-12-18_recipe_flavor_table_created.py * logic/ * __init__.py * calculations.py # pure functions for PG/VG/nic/flavor math * resources/ # icons, etc. * tests/ * test_calculations.py ## Requirements * Python 3.8+ * Toga * Peewee * playhouse.migrate * Briefcase ## DB Schema * Enable FKs on connection: `SqliteDatabase(path, pragmas={'foreign_keys': 1})` * Flavor Table ``` flavor( id INTEGER PRIMARY KEY, name TEXT COLLATE NOCASE NOT NULL, company TEXT COLLATE NOCASE NOT NULL, base TEXT CHECK(base IN ('PG','VG')) NOT NULL, is_deleted INTEGER NOT NULL DEFAULT 0, deleted_at DATETIME NULL, UNIQUE(name, company) ) CREATE INDEX IF NOT EXISTS idx_flavor_name_company ON flavor(name, company); ``` * Recipe Table ``` recipe( id INTEGER PRIMARY KEY, name TEXT NOT NULL UNIQUE, size_ml INTEGER NOT NULL, base_pg_pct REAL NOT NULL, base_vg_pct REAL NOT NULL, nic_target_mg_per_ml REAL CHECK(nic_target_mg_per_ml >= 0) NOT NULL, nic_strength_mg_per_ml REAL CHECK(nic_strength_mg_per_ml > 0) NOT NULL, nic_base TEXT CHECK(nic_base IN ('PG','VG')) NOT NULL, nicotine_id INTEGER NULL REFERENCES nicotine(id) ON DELETE SET NULL, CHECK ( base_pg_pct >= 0 AND base_vg_pct >= 0 AND base_pg_pct + base_vg_pct = 100 ) ) ``` * RecipeFlavor XREF Table ``` recipe_flavor( id INTEGER PRIMARY KEY, recipe_id INTEGER NOT NULL, flavor_id INTEGER NOT NULL, pct REAL CHECK(pct >= 0 AND pct <= 100) NOT NULL, FOREIGN KEY(recipe_id) REFERENCES recipe(id) ON DELETE CASCADE, FOREIGN KEY(flavor_id) REFERENCES flavor(id) ON DELETE RESTRICT, UNIQUE(recipe_id, flavor_id) ) CREATE INDEX IF NOT EXISTS idx_recipe_flavor_recipe ON recipe_flavor(recipe_id); CREATE INDEX IF NOT EXISTS idx_recipe_flavor_flavor ON recipe_flavor(flavor_id); ``` * Migrations Table ``` migrations( id INTEGER PRIMARY KEY, batch INTEGER NOT NULL, filename TEXT NOT NULL, date_run DATETIME NOT NULL, UNIQUE(filename) ) ``` ## Recipes * Recipes require * Name, * Size (ml), * PG Base %, * VG base %, * Nic target strength (mg/mL), * Nic stock strength (mg/mL), * Nic Base (PG/VG), * Flavor List * Recipe equations are: * flavor_pg_pct = sum(pct for flavor in flavors if flavor.base == 'PG') * flavor_vg_pct = sum(pct for flavor in flavors if flavor.base == 'VG') * nic_pct = (nic_target_mg_per_ml / nic_strength_mg_per_ml) * 100 # nicotine volume percent of final mix * pg_pct = base_pg_pct - flavor_pg_pct - (nic_pct if nic_base == 'PG' else 0) * vg_pct = base_vg_pct - flavor_vg_pct - (nic_pct if nic_base == 'VG' else 0) * PG_ml = pg_pct / 100 * size_ml * VG_ml = vg_pct / 100 * size_ml * Nic_ml = nic_pct / 100 * size_ml * Flavor_i_ml = flavor_i.pct / 100 * size_ml * Recipe output is: * Recipe Name * Size (ml) * PG: PG_ml * VG: VG_ml * Nic: Nic_ml * Flavors * If viewing individual flavors: * Flavor 1 Name: Flavor_1_ml * Flavor 2 Name: Flavor_2_ml * if viewing Flavor Prep: * "Flavor Prep": Flavor_Prep_ml (sum(pct for flavor in flavors)/100 * size_ml) ## Main Screen * Menu Bar with File, Recipes, and Help * File Menu * New Recipe * Exit * Recipes Menu * Shows a list of saved recipes as menu items * Selecting menu item opens the recipe screen * Help Menu * About screen * Shows Saved Recipes List * Recipe Name * Button to open the recipe screen (View only) * Button to edit the recipe (Edit form) * Button to delete the recipe (Confirmation dialog) * Has button on top to open Inventory Screen. ## About Screen * Shows * Application Name (FunkyJuice Recipes) * Version Number * Author (Funky Waddle) ## View Recipe Screen * Needs access to soft deleted flavors for recipes that currently use deleted flavors. * Show components rounded to 1 decimal point * Just convert percentage to decimal number and display, no need to validate sum(amounts) = Size (ml) * We store the percent in the database, not the actual amount, so there is no check that the amount adds to size (ml), only that sum(percentages) = 100 * Shows recipe name, * Amount in ml, * PG: PG_ml, (rounded to 1 decimal point) * VG: VG_ml, (rounded to 1 decimal point) * Nic: Nic_ml, (rounded to 1 decimal point) * Flavors * If viewing individual flavors: * Flavor 1 Name: Flavor_1_ml (rounded to 1 decimal point) * Flavor 2 Name: Flavor_2_ml (rounded to 1 decimal point) * if viewing Flavor Prep: * Flavor Prep Amount * Has button to open Edit Recipe Screen * Has button to open Delete Recipe * Has button to open Inventory Screen * Has Drop Down for "View Recipe Using" * Options are "Individual Flavors" or "Flavor Prep" * When Flavor Prep is chosen, it replaces the list of flavors with a total ml of the sum of all the flavors in the recipe. * When Individual Flavors is chosen (default), it replaces the Flavor Prep with the list of individual flavors. * Has button for "Mix Flavor Prep" * Flavor Prep is not stored in database. It's calculated on the fly based on flavors in the recipe. * Shows recipe of Flavors only, no PG, VG, or Nicotine * Flavor Prep is a batch of flavors * Take ratio of each flavor to get total amount of each flavor (12% flavor 1 + 2% flavor 2 = 6:1 ratio) * Flavor Prep batches of different sizes (10ml, 30ml, 120ml) * Flavor Prep is just pre-mixing flavors, much like premixing flour and sugar for use in cookie batter. * You can make 10ml or 1000ml of Sugar Flour, and it won't change the amount used in the cookie recipe. * You can pre-mix 1000ml of flavors, and use 10ml per recipe, giving you 100 batches of Flavor Prep. ## Edit Recipe Screen * Needs access to soft deleted flavors for recipes that currently use deleted flavors. * Has a form for editing the recipe * Name * Size (mL) (10ml, 30ml, 120ml) * PG Base % * VG Base % * Nic Base (PG or VG) * Nic % * Flavors * Name (with company) * Amount % * If recipe contains a deleted flavor, * Recipe can be saved with the deleted flavor still in the list, as long as it was not changed. * Cannot actively change a flavor to a deleted flavor. * Has button to save changes (Save form, then return to View Recipe screen) * Has button to cancel changes (Discard form changes, return to View Recipe screen) * Has button to delete recipe (Confirmation dialog) * On Save, if recipe contains duplicate flavors, * If duplicate flavor is found: * Show question dialog to ask if user wants to "Combine the duplicate flavors" or "Cancel Save" and return to Edit Recipe screen. * If user chooses to combine duplicate flavors, * Combine flavors with same name and company into one flavor with combined percentage. * If user chooses to cancel save, * DO NOT SAVE and close dialog box, returning user to Edit Recipe screen. * Validate that (PG Base + VG Base) === 100 * Validate that neither PG Base nor VG Base is < 0 * Validate sum(flavor.pct) + nic.pct + pg.pct + vg.pct ~= 100 * On App Exit, if on Edit Recipe screen and data has changed, ask to save changes before exiting. * On Screen Change, if on Edit Recipe screen and data has changed, ask to save changes before changing screens. ## Add Recipe Screen * Has a form for adding a recipe * Name * Size (mL) (10ml, 30ml, 120ml) * PG Base % * VG Base % * Nic Base (PG or VG) * Nic % * Flavors * Name * Amount % * Has button to save changes (Save form, then return to View Recipe screen) * Has button to cancel changes (Discard form changes, return to View Recipe screen) * On Save, if recipe contains duplicate flavors, * If duplicate flavor is found: * Show question dialog to ask if user wants to "Combine the duplicate flavors" or "Cancel Save" and return to Edit Recipe screen. * If user chooses to combine duplicate flavors, * Combine flavors with same name and company into one flavor with combined percentage. * If user chooses to cancel save, * DO NOT SAVE and close dialog box, returning user to Edit Recipe screen. * Validate that (PG Base + VG Base) === 100 * Validate that neither PG Base nor VG Base is < 0 * Validate sum(flavor.pct) + nic.pct + pg.pct + vg.pct ~= 100 * On App Exit, if on Edit Recipe screen and data has changed, ask to save changes before exiting. * On Screen Change, if on Edit Recipe screen and data has changed, ask to save changes before changing screens. ## Recipe Form Screens (Add/Edit) * When adding/editing a recipe, you enter the PG, VG, Flavors, and Nic as % * PG and VG are entered as % of total recipe size * When changing PG or VG, the other is updated automatically so that PG + VG is always 100% * Neither PG or VG can be negative ## Inventory Screen * List of all Non-Deleted Flavors saved in the database * Flavor Name * Company * PG or VG Base * Has button to view Soft Deleted Flavors, and how many recipes they are in * Can Hard Delete Soft Deleted Flavors if they are no longer in a recipe * Can Un-Delete a Soft Deleted Flavor * Each Flavor has a button to edit the flavor (Name, Company, PG or VG Base) * Each Flavor has a button to delete the flavor * When deleting a flavor, * If flavor is in a recipe, * Soft Delete the flavor * Allow flavor data to be viewed in recipe, but with a strikethrough * If flavor is not in a recipe, * Hard Delete the flavor * NO DUPLICATES ALLOWED