FunkyJuiceRecipes/SPECS.md
Funky Waddle c8655e24e4 feat(db,models,dao,logic): nicotine inventory + flavor snapshots; drop legacy nic_pct; migrations, tests
• Migrations
	◦ Create nicotine table with NOCASE strings, base PG/VG check, strength_mg_per_ml (>0), soft-delete, and optional inventory metadata (bottle_size_ml, cost, purchase_date).
	◦ Alter recipe: add nic_target_mg_per_ml (NOT NULL), nic_strength_mg_per_ml (NOT NULL), nicotine_id (FK ON DELETE SET NULL); keep nic_base. Drop legacy nic_pct via rebuild migration.
	◦ Alter flavor: add bottle_size_ml, cost, purchase_date (nullable).
	◦ Alter recipe_flavor: add flavor snapshot columns (flavor_name_snapshot, flavor_company_snapshot NOCASE, flavor_base_snapshot PG/VG CHECK).
	◦ Ensure migration order runs “_created” before ALTERs.
• Models
	◦ Add Nicotine model.
	◦ Update Recipe with nic_target_mg_per_ml, nic_strength_mg_per_ml, nic_base, optional nicotine FK.
	◦ Update Flavor with optional inventory metadata.
	◦ Update RecipeFlavor with snapshot fields and unique index remains.
• DAOs
	◦ Add NicotineRepository (CRUD, list with soft-delete filters; hard_delete_when_safe blocked when referenced by recipes).
	◦ Update RecipeRepository:
		▪ create/update accept nic_target_mg_per_ml, nic_strength_mg_per_ml, nic_base, optional nicotine_id.
		▪ If nicotine_id provided, snapshot strength/base from inventory unless overridden.
		▪ replace_ingredients uses diff-based updates and populates flavor snapshots on insert.
	◦ Update FlavorRepository to edit inventory metadata; soft/undelete; hard delete only when safe.
• DB init
	◦ Bind Peewee models to runtime DB; tweak migration ordering logic.
• Logic
	◦ Add calculation engine (compute_breakdown, breakdown_for_view) supporting target nicotine mg/mL and stock strength mg/mL; rounding policy for display.
• Tests
	◦ Update/init DB tests to include nicotine table and flexible migration counts.
	◦ Add calculation tests (120 mL example, nic in VG, 50 mg/mL stock, validations).
	◦ Add DAO tests including flavor snapshots stability and replace_ingredients diff behavior.
	◦ All tests passing: 15 passed.
• Docs
	◦ Update SPECS and MILESTONES; strike through Milestones 3 and 4.

BREAKING CHANGE: remove Recipe.nic_pct field and any compatibility code. API changes in RecipeRepository.create/update now require mg/mL fields and use snapshot+FK model for nicotine.
2025-12-18 19:55:22 -06:00

13 KiB
Raw Blame History

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 peruser data directory at app.paths.data
    • The app stores the SQLite DB at <app.paths.data>/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