diff --git a/docs/help/README.md b/docs/help/README.md new file mode 100644 index 0000000..7b0c3ef --- /dev/null +++ b/docs/help/README.md @@ -0,0 +1,28 @@ +# Formulation Documentation & Knowledge Base + +Welcome to the Formulation Help Center. This documentation explains the architecture, business logic, and operational workflows for managing recipes, ingredients, costs, units of measure, inventory, and archives. + +--- + +## Knowledge Base Directory + +### 🚀 Getting Started +- [Workspace Navigation & Global Search](./getting-started/workspace-navigation.md): Navigating workspaces, filtering catalogs, global search, and keyboard shortcuts. + +### 🍳 Recipes & Formulas +- [Scaling, Batching & Yield Calculations](./recipes/scaling-and-yields.md): Interactive scaling, yield conversions, weight-based auto-yields, and portion control. +- [Baker's & Standard Percentages](./recipes/bakers-percentages.md): Flour basis calculation, dynamic target weights, and formula ratios. +- [Sub-recipes & Prep Methods](./recipes/sub-recipes-and-prep.md): Nesting recipes as ingredients, prep instructions, headers, notes, and equipment tracking. + +### 🌿 Ingredients & Units of Measure +- [Units of Measure & Custom Equivalencies](./ingredients/units-and-equivalencies.md): Dimensional systems (mass, volume, count), canonical conversions, density measures, and USDA nutrition mapping. + +### 💰 Costing & Purchasing +- [Purchase Items, Pack Sizes & Recipe Costing](./costing/purchase-items-and-costing.md): Invoices, pack configurations, yield factors, price history, food cost per batch, and cost per serving. + +### 📦 Inventory Management +- [Inventory Steps for Success (5-Week Implementation Roadmap)](./inventory/inventory-calendar-steps-for-success.md): Full operational guide for going from initial setup to first live period-end inventory count. +- [Count Sheets & Storage Locations](./inventory/count-sheets-and-locations.md): Location-specific sheet-to-shelf counting, on-hand inputs, and live extended valuations. + +### 🗄️ Archive & Trash Lifecycle +- [Archive & Lifecycle Management](./archive/lifecycle-and-restoration.md): Soft-deletion, catalog filtering, multi-item batch restore, and safe permanent deletion guards. diff --git a/docs/help/archive/lifecycle-and-restoration.md b/docs/help/archive/lifecycle-and-restoration.md new file mode 100644 index 0000000..05eea75 --- /dev/null +++ b/docs/help/archive/lifecycle-and-restoration.md @@ -0,0 +1,23 @@ +# Archive & Lifecycle Management + +Formulation implements a two-stage deletion lifecycle (Soft Delete $\rightarrow$ Hard Delete) with automated dependency safeguards to protect culinary data integrity. + +--- + +## 1. Soft Deletion (Archiving) + +- When an ingredient, recipe, or recipe book is deleted, it is **soft-deleted** (`deleted_at` timestamp recorded) rather than purged immediately. +- Archived items are immediately hidden from active searches, auto-complete dropdowns, and directory views. +- Active recipes that historically reference an archived ingredient remain intact without breaking calculations. + +--- + +## 2. Archive Workspace (`/app/archive/`) + +The Archive workspace allows viewing and managing all removed items: +- **Filter by Entity**: Filter by *All*, *Recipes*, *Ingredients*, or *Recipe Books*. +- **Multi-Item Batch Selection**: Select multiple items using checkboxes to perform bulk actions. +- **Batch Restore**: Instantly restore selected items back to the active catalog. +- **Safe Permanent Deletion**: + - Permanently purges items from the database. + - **Dependency Safeguard**: Formulation automatically verifies whether an item is still referenced by any active recipe or sub-recipe. If dependencies exist, hard deletion is blocked with a clear warning explaining where the item is currently used. diff --git a/docs/help/costing/purchase-items-and-costing.md b/docs/help/costing/purchase-items-and-costing.md new file mode 100644 index 0000000..5d025ec --- /dev/null +++ b/docs/help/costing/purchase-items-and-costing.md @@ -0,0 +1,25 @@ +# Purchase Items, Pack Sizes & Recipe Costing + +Accurate recipe food costing relies on mapping real-world vendor purchase packages to canonical ingredients. + +--- + +## 1. Purchase Items & Pack Configurations + +A **Purchase Item** represents a commercial package purchased from a vendor or distributor: +- **Pack Size & Unit**: e.g., `50 lb Bag`, `6 x 1 Gallon Case`, `16 oz Container`. +- **Cost**: Total package purchase price (e.g. `$24.50`). +- **Yield Factor (%)**: The usable portion percentage after trimming or prep (e.g. 85% usable yield on trimmed beef tenderloin, 100% on flour). +- **Unit Cost**: Automatically computed per base unit (e.g. `$0.00108 / gram` or `$0.49 / lb`). + +--- + +## 2. Recipe Food Costing Breakdown + +When viewing a recipe's **Cost** tab: +1. **Line Cost**: Each ingredient's line item cost is calculated as: + $$\text{Line Cost} = \frac{\text{Quantity} \times \text{Unit Cost}}{\text{Yield Factor}}$$ +2. **Total Batch Cost**: The sum of all line item costs for the batch. +3. **Cost per Serving**: Total Batch Cost divided by total yield servings. +4. **Food Cost % (Target Selling Price)**: + $$\text{Suggested Price} = \frac{\text{Cost per Serving}}{\text{Target Food Cost \%}}$$ diff --git a/docs/help/getting-started/workspace-navigation.md b/docs/help/getting-started/workspace-navigation.md new file mode 100644 index 0000000..32d781f --- /dev/null +++ b/docs/help/getting-started/workspace-navigation.md @@ -0,0 +1,36 @@ +# Workspace Navigation & Global Search + +Formulation provides a streamlined, fast, centralized directory for managing all culinary data across your operation. + +--- + +## 1. Directory Workspace & Workspace Pills + +The home directory (`/app/`) categorizes items into distinct workspaces using top pill badges: + +- **Recipes** (Blue badge): Standalone formulas, prep recipes, and batch formulations. +- **Ingredients** (Green badge): Raw culinary ingredients, allergens, density conversions, and supplier links. +- **Recipe Books** (Purple badge): Curated collections and menus of recipes (e.g. *Dinner Menu*, *Cocktails*, *Bakery Line*). +- **Purchase Items** (Cyan badge): Commercial vendor packages, invoice pack sizes, prices, and vendor SKUs. +- **Inventory** (Teal badge): Active and past inventory count sessions with on-hand valuations. +- **Archive** (Neutral link): Soft-deleted items ready for restoration or permanent purge. + +--- + +## 2. Global Search & Autocompletion + +- **Omnibox Search**: Search across recipe titles, ingredient names, aliases, and purchase items simultaneously. +- **Type Filtering**: Narrow search results by specific entity type directly from the search dropdown filter. +- **Keyboard Navigation**: + - Tab / Arrow Down: Highlight matching search candidates. + - Enter: Open the selected recipe or ingredient detail card immediately. + - Escape: Clear search and close active popups. + +--- + +## 3. Detail Utility Bar + +Every single item detail page features a fixed top utility bar containing: +- **Workspace Breadcrumbs**: Direct navigation back to the active directory workspace. +- **Global Search**: Search and jump to other items without returning to the home screen. +- **New Action Button (`+ New`)**: Quick creation modal for recipes, ingredients, recipe books, or count sessions from anywhere in the app. diff --git a/docs/help/ingredients/units-and-equivalencies.md b/docs/help/ingredients/units-and-equivalencies.md new file mode 100644 index 0000000..53b3a77 --- /dev/null +++ b/docs/help/ingredients/units-and-equivalencies.md @@ -0,0 +1,30 @@ +# Units of Measure & Custom Equivalencies + +Formulation maintains a rigorous, multi-dimensional unit conversion engine that enforces physical dimensional rules while supporting culinary volume-to-weight equivalencies. + +--- + +## 1. Dimensional Systems + +Every unit belongs to a fundamental physical dimension: +- **Mass** (Base unit: `gram`): `gram`, `kilogram`, `pound`, `ounce_mass`. +- **Volume** (Base unit: `milliliter`): `milliliter`, `liter`, `cup_us` (240 mL legal), `tablespoon_us`, `teaspoon_us`, `fluid_ounce_us`. +- **Count** (Base unit: `each`): `each`, `clove`, `head`, `bunch`. +- **Temperature** (Affine scale): `fahrenheit`, `celsius`. + +--- + +## 2. Density & Ingredient-Specific UoM Equivalencies + +Because ingredients possess different bulk densities (e.g. 1 cup of all-purpose flour = ~120g, whereas 1 cup of honey = ~340g), volume-to-mass conversions require density records. + +### A. UoM Equivalencies Panel +- On each ingredient page, the **UoM Equivalency** tab allows defining custom portion measurements: + - *Example*: `1 cup = 125 g` + - *Example*: `1 medium apple = 182 g` + - *Example*: `1 clove garlic = 3 g` + +### B. Resolution Precedence +1. **Reviewed Portions / Measures**: Checked first for an exact unit match (e.g. `cup` or `each`). +2. **Bulk Density Measurements**: Checked if converting between standard volume and mass dimensions. +3. **Canonical Unit Factor**: Applied for within-dimension conversions (e.g. `lb` to `oz`). diff --git a/docs/help/inventory/count-sheets-and-locations.md b/docs/help/inventory/count-sheets-and-locations.md new file mode 100644 index 0000000..cf1ac17 --- /dev/null +++ b/docs/help/inventory/count-sheets-and-locations.md @@ -0,0 +1,24 @@ +# Count Sheets & Storage Locations + +Inventory in Formulation is built for high-speed, sheet-to-shelf counting across physical storage locations. + +--- + +## 1. Storage Locations + +Organize your kitchen into logical physical zones: +- **Walk-in Cooler**: Dairy, produce, raw proteins, prepped batch items. +- **Dry Storage**: Flours, grains, spices, oils, canned goods. +- **Freezer**: Frozen stocks, puff pastry, frozen proteins. +- **Bar / Front of House**: Spirits, syrups, mixers, garnishes. +- **Line Stations**: Sauté station drawers, prep table bins. + +--- + +## 2. Conducting an Inventory Count + +1. **Start a Count Session**: Open `/app/inventory/` and click **+ New Count Session**. +2. **Sheet-to-Shelf Counting**: Open the count sheet and filter by physical location. Items appear in the exact physical order of your storage shelves. +3. **Enter On-Hand Quantities**: Type counted units (e.g. `4.5` bags, `12` each, `25` lbs). +4. **Live Valuation**: Formulation instantly computes the **Extended Value ($)** for each item based on current vendor purchase costs. +5. **Complete & Finalize**: Submit the count to freeze period-end inventory valuation. diff --git a/docs/help/inventory/inventory-calendar-steps-for-success.md b/docs/help/inventory/inventory-calendar-steps-for-success.md new file mode 100644 index 0000000..f1f2aa0 --- /dev/null +++ b/docs/help/inventory/inventory-calendar-steps-for-success.md @@ -0,0 +1,100 @@ +# Inventory Steps for Success + +A structured 5-week roadmap to build out recipes, configure purchasing units and costs, organize location-specific count sheets, test inventory counting, and successfully conduct your first live inventory. + +--- + +## 5-Week Roadmap Overview + +```mermaid +gantt + title Inventory Onboarding & Setup Roadmap + dateFormat X + axisFormat Day %d + section Week 1 + Recipes & Ingredients Setup :active, 1, 7 + section Week 2 + Purchasing Units & Costs :2, 14 + section Week 3 + Location Count Sheets :3, 21 + section Week 4 + Dry-Run Test Counts :4, 28 + section Week 5 + First Live Inventory & Analytics :5, 35 +``` + +--- + +## Week 1: Build Out Recipes & Ingredients Tables + +> **Week 1 Goal**: Your complete recipe database and canonical ingredient list are populated and ready for kitchen use. + +| Day | Action Item | Details & Instructions | +|---|---|---| +| **Monday** | **Goal Kickoff** | Define the scope of prep items, sub-recipes, and raw ingredients to be tracked. | +| **Tuesday** | **Start with Prep Recipes** | Begin by entering your prep recipes and sub-recipes. As you add prep recipes, your canonical ingredient list will automatically populate. | +| **Wednesday** | **Audit & Merge Ingredients** | Review your ingredient catalog. Identify duplicates or near-duplicates (e.g. "kosher salt" vs "salt kosher") and merge them into single canonical ingredients. | +| **Thursday** | **Duplicate Multi-Type Items** | Make distinct copies of ingredients where you use multiple varieties or grades of the same item (e.g. *Flour - All Purpose* vs *Flour - Bread High Gluten*). | +| **Friday** | **Review Kitchen Database** | Verify that recipes have components, steps, and yields properly structured. | +| **Saturday & Sunday** | **Milestone Check** | **Look at that!** You now have a complete, standardized recipe database that can be used actively on the kitchen line. | + +--- + +## Week 2: Configure Costs & Purchase Units + +> **Week 2 Goal**: All inventoried ingredients have verified purchase packages, unit costs, and yield factors. + +| Day | Action Item | Details & Instructions | +|---|---|---| +| **Monday** | **Goal Kickoff** | Gather recent supplier invoices, receipts (e.g., Walmart, Sam's Club, US Foods, Sysco), and vendor order guides. | +| **Tuesday** | **Invoice Processing & Linking** | Ingest invoice lines into the system to extract package sizes, prices, and vendor SKU codes. | +| **Wednesday** | **Manual Costing** | For specialty or local market items without digital invoices, enter package costs manually on the ingredient cost panel. | +| **Thursday** | **Spreadsheet Import** | If you maintain vendor price lists in spreadsheets, upload or batch-map purchase packages into your catalog. | +| **Friday** | **Map New Purchase Items** | Use the Purchase Items table to map raw invoice line descriptions to their canonical formulation ingredients. | +| **Saturday** | **Audit Missing Costs** | Filter your ingredient directory to inspect which items are still unpriced. Add missing package sizes. | +| **Sunday** | **Milestone Check** | **Prep recipes now show real costs!** Take a well-deserved break—your recipe costing foundation is complete. | + +--- + +## Week 3: Build Location Count Sheets + +> **Week 3 Goal**: Sheet-to-shelf inventory count lists are configured for each physical storage area. + +| Day | Action Item | Details & Instructions | +|---|---|---| +| **Monday** | **Goal Kickoff** | Identify all physical storage areas across your operation (e.g., *Walk-In Cooler*, *Dry Storage*, *Freezer*, *Bar*, *Line Drawers*). | +| **Tuesday** | **Create Count Sheets** | Go to **+ New** and select **Count Sheet**. Ensure count sheets are strictly location-specific. *(Note: Only managers can create count templates).* | +| **Wednesday** | **Order "Sheet to Shelf"** | Arrange ingredients in the exact physical order they appear on your shelves (top-to-bottom, left-to-right). This maximizes counting speed and prevents missed items. | +| **Thursday** | **Add Ingredients & Batches** | Add raw ingredients (green icon) and prepped batch recipes (blue icon) to each count sheet. Drag and drop to reorder. | +| **Friday** | **Set Count Units** | Verify and adjust count units (e.g. *Cases*, *Bags*, *Each*, *Pounds*) to match how cooks physically count each shelf. Count units default to the ingredient's primary purchase unit. | +| **Saturday & Sunday** | **Milestone Check** | **Almost there!** All location count sheets are structured and ready for validation. | + +--- + +## Week 4: Test Run & Validate Inventory Lists + +> **Week 4 Goal**: Perform a dry-run test count to uncover unit mismatch errors, pack size discrepancies, or missing items. + +| Day | Action Item | Details & Instructions | +|---|---|---| +| **Monday** | **Goal Kickoff** | Schedule a 20-minute test run with key kitchen leads before service. | +| **Tuesday** | **Enter Test Count Values** | Enter a dummy quantity of **`1`** in every column (or enter last month's closing count). Save and submit each location sheet individually (do not submit total final count). | +| **Wednesday** | **Export Valuation Report** | Review the calculated on-hand values and line-item totals in the analytics review. | +| **Thursday** | **Identify Discrepancies** | Look for extended dollar values that look unusually high or low. This highlights where pack sizes (e.g. $50/case counted as 1 ea = $50 vs $2.08) or count units need calibration. | +| **Friday** | **Correct Count Templates** | Update pack sizes, count units, or ingredient equivalencies based on test run findings. | +| **Saturday & Sunday** | **Milestone Check** | **Take a deep breath!** Your inventory templates are calibrated, validated, and ready for real operational use. | + +--- + +## Week 5: Conduct Your First Live Inventory + +> **Week 5 Goal**: Successfully execute full period-end inventory, capture total valuation, and establish your inventory baseline. + +| Day | Action Item | Details & Instructions | +|---|---|---| +| **Monday** | **Conduct Live Count** | Assign team members to their respective locations with mobile devices or clipboards. | +| **Tuesday** | **Add Items On the Fly** | If an unlisted item is discovered on a shelf during the count, add it on the fly. *(Remember to add it to the master count template afterward).* | +| **Wednesday** | **Review & Submit Count** | Once all location lists are filled, managers review pending location totals and submit the total inventory count. | +| **Thursday** | **Analyze Inventory Valuation** | Review the total dollar valuation report by storage location and ingredient category. *(Calculations finalize within minutes).* | +| **Friday** | **Export Accounting Reports** | Export your finalized inventory valuation breakdown categorized by GL accounting codes for bookkeeping. | +| **Saturday & Sunday** | **Celebrate Success!** | You now have a repeatable, accurate, high-speed inventory process embedded into your culinary operations! | diff --git a/docs/help/recipes/bakers-percentages.md b/docs/help/recipes/bakers-percentages.md new file mode 100644 index 0000000..4581ec2 --- /dev/null +++ b/docs/help/recipes/bakers-percentages.md @@ -0,0 +1,26 @@ +# Baker's & Standard Percentages + +In baking and commercial food manufacturing, formulas are often structured using **percentages** to ensure recipe scalability and hydration control. + +--- + +## 1. Standard Percentage vs. Baker's Percentage + +### Standard % (Total Formulation Basis) +$$\text{Standard \%} = \frac{\text{Ingredient Weight}}{\text{Total Batch Weight}} \times 100$$ +- In Standard Percentage mode, the sum of all ingredient percentages in the recipe equals **100%**. +- Useful for confectionery, dressings, beverages, and general culinary batching. + +### Baker's % (Flour / Basis Member Basis) +$$\text{Baker's \%} = \frac{\text{Ingredient Weight}}{\text{Total Basis Flour Weight}} \times 100$$ +- In Baker's Percentage mode, the flour(s) or designated base ingredients are flagged as **Base Members** (`basis_member = true`) and sum to **100%**. +- All other ingredients (water/hydration, salt, yeast, sugar, butter) are expressed as a percentage relative to the total flour weight (e.g. 75% hydration water, 2% salt, 1.5% yeast). + +--- + +## 2. Dynamic Interactive Percentage Editing + +When editing in the Recipe Structure Editor: +1. Enable **Calculate %** and select **Baker's %** or **Standard %**. +2. For Baker's %, toggle the **Base** checkbox on the flour/grain ingredients. +3. Editing an ingredient's percentage dynamically recalibrates its required physical weight and quantity in grams automatically! diff --git a/docs/help/recipes/scaling-and-yields.md b/docs/help/recipes/scaling-and-yields.md new file mode 100644 index 0000000..16fa3c8 --- /dev/null +++ b/docs/help/recipes/scaling-and-yields.md @@ -0,0 +1,34 @@ +# Scaling, Batching & Yield Calculations + +Formulation is a weight-first formulation engine designed to scale recipes accurately across commercial batch sizes without calculation rounding drift. + +--- + +## 1. Batch Multipliers vs. Yield-Target Scaling + +Recipes can be scaled in two primary modes: + +### A. Batch Multiplier (`x` factor) +- Entering a batch multiplier (e.g. `0.5x`, `2x`, `5x`, `10x`) multiplies every ingredient quantity by the exact factor. +- Pre-configured quick buttons allow instant one-tap scaling during active kitchen prep. + +### B. Target Yield Scaling +- Specify an exact required total batch yield (e.g., scale a sauce recipe to yield exactly `1,500 grams` or `4.5 quarts`). +- Formulation calculates the precise scale factor required to produce that yield based on total recipe weight. + +--- + +## 2. Weight-Based Auto-Yield Calculation + +When **Auto-Calculate Total Yield** is toggled ON: +1. Formulation calculates the weight in grams for every ingredient in the recipe using canonical unit conversion factors or ingredient-specific density measurements. +2. The total recipe yield quantity is automatically updated as the exact sum of all ingredient weights. +3. If an ingredient cannot be converted to weight (due to a missing UoM volume-to-weight equivalency), an informative notice displays: *"Auto yield excludes N ingredient amounts without a weight equivalency."* + +--- + +## 3. Unit Conversion Safety + +- Ingredients measured in mass (e.g. `g`, `kg`, `oz`, `lb`) convert losslessly across all mass units. +- Ingredients measured in volume (e.g. `cup`, `tbsp`, `tsp`, `liter`, `ml`) require an ingredient density measurement (e.g. `1 cup = 120g`) to convert to mass. +- Cross-dimensional universal conversions without density data are intentionally rejected to maintain strict culinary accuracy. diff --git a/docs/help/recipes/sub-recipes-and-prep.md b/docs/help/recipes/sub-recipes-and-prep.md new file mode 100644 index 0000000..e19a870 --- /dev/null +++ b/docs/help/recipes/sub-recipes-and-prep.md @@ -0,0 +1,23 @@ +# Sub-recipes & Prep Methods + +Recipes in Formulation can seamlessly nest other recipes as **sub-recipes**, enabling modular batch preparation and accurate cost/nutrition rollup. + +--- + +## 1. Using Sub-recipes in Formulations + +- **Nesting**: When adding an ingredient to a recipe row, you can select an existing Recipe (tagged with the blue Recipe badge) rather than a raw ingredient. +- **Cascading Cost & Nutrition**: + - The sub-recipe's unit cost and nutritional profile are calculated based on its own ingredients and yield, and cascaded into the parent recipe. + - Changes to a base sub-recipe (e.g. *House Mayonnaise*) automatically propagate up to all dishes that include it (e.g. *Aioli*, *Tartar Sauce*, *Sandwich Spread*). + +--- + +## 2. Structured Prep Method + +The **Prep Method** editor allows organizing kitchen instructions into numbered, ordered steps: + +- **Numbered Instructions**: Step-by-step prep directions with drag-and-drop reordering handles. +- **Section Headers**: Add intermediate headings (e.g. `To Sear:`, `Dry Mix:`, `To Garnish:`) by ending the line with a colon `:`. +- **Prep Notes**: Add inline notes (e.g. `(Let rest for 15 minutes before carving)`) by wrapping text in parentheses `(...)`. +- **Bulk Prep Import**: Paste entire text documents or recipes into the bulk import modal to automatically parse headings, numbered steps, and notes into structured cards. diff --git a/migrations/003_inventory.sql b/migrations/003_inventory.sql new file mode 100644 index 0000000..138c9f5 --- /dev/null +++ b/migrations/003_inventory.sql @@ -0,0 +1,37 @@ +PRAGMA foreign_keys = ON; + +CREATE TABLE IF NOT EXISTS inventory_locations ( + id TEXT PRIMARY KEY, + name TEXT NOT NULL, + position INTEGER NOT NULL, + deleted_at TEXT +); + +CREATE TABLE IF NOT EXISTS inventory_counts ( + id TEXT PRIMARY KEY, + title TEXT NOT NULL, + counted_at TEXT NOT NULL, + status TEXT NOT NULL DEFAULT 'open', + notes TEXT, + created_at TEXT NOT NULL, + deleted_at TEXT +); + +CREATE TABLE IF NOT EXISTS inventory_count_items ( + count_id TEXT NOT NULL REFERENCES inventory_counts(id) ON DELETE CASCADE, + location_id TEXT REFERENCES inventory_locations(id), + ingredient_id TEXT NOT NULL REFERENCES ingredients(id), + quantity REAL NOT NULL, + unit_id TEXT NOT NULL REFERENCES units(id), + unit_cost REAL, + extended_cost REAL, + PRIMARY KEY (count_id, location_id, ingredient_id) +); + +-- Seed baseline standard locations if table is empty +INSERT OR IGNORE INTO inventory_locations (id, name, position, deleted_at) VALUES + ('loc_walk_in', 'Walk-in Cooler', 1, NULL), + ('loc_dry_storage', 'Dry Storage', 2, NULL), + ('loc_freezer', 'Freezer', 3, NULL), + ('loc_bar', 'Bar & Service', 4, NULL), + ('loc_line', 'Prep Line', 5, NULL); diff --git a/migrations/004_archive_parity.sql b/migrations/004_archive_parity.sql new file mode 100644 index 0000000..444a125 --- /dev/null +++ b/migrations/004_archive_parity.sql @@ -0,0 +1,2 @@ +-- Soft delete parity for purchase items +ALTER TABLE purchase_items ADD COLUMN deleted_at TEXT; diff --git a/src/application/pages/app/archive.astro b/src/application/pages/app/archive.astro index e0266e2..9ec496f 100644 --- a/src/application/pages/app/archive.astro +++ b/src/application/pages/app/archive.astro @@ -2,30 +2,102 @@ export const prerender = false; import BaseLayout from "../../../layouts/BaseLayout.astro"; import DetailUtility from "../../../components/DetailUtility.astro"; -import { openDatabase, refreshSiteProjection } from "../../../lib/database"; +import { + openDatabase, + permanentlyDeleteArchivedItems, + restoreArchivedItems, +} from "../../../lib/database"; import { titleCase } from "../../../lib/format"; +import { readOnlyMode } from "../../../lib/runtime"; const database = openDatabase({ readOnly: false }); if (!database) return Astro.redirect("/app/", 303); +let error = ""; if (Astro.request.method === "POST") { - const form = await Astro.request.formData(); - const type = String(form.get("type")); - const id = String(form.get("id")); - const tables: Record = { recipe: "recipes", ingredient: "ingredients", book: "collections" }; - if (tables[type]) { - database.prepare(`UPDATE ${tables[type]} SET deleted_at=NULL${type === "ingredient" ? ",status='active'" : ""} WHERE id=?`).run(id); - refreshSiteProjection(database); + try { + const form = await Astro.request.formData(); + const intent = String(form.get("intent") ?? "restore"); + + if (intent === "restore") { + const type = String(form.get("type")); + const id = String(form.get("id")); + restoreArchivedItems(database, [{ id, type }]); + database.close(); + return Astro.redirect("/app/archive/", 303); + } + + if (intent === "delete") { + const type = String(form.get("type")); + const id = String(form.get("id")); + permanentlyDeleteArchivedItems(database, [{ id, type }]); + database.close(); + return Astro.redirect("/app/archive/", 303); + } + + if (intent === "batch_restore") { + const selectedItems = form.getAll("selected_item").map((val) => { + const [type, id] = String(val).split(":", 2); + return { type, id }; + }); + if (selectedItems.length > 0) { + restoreArchivedItems(database, selectedItems); + } + database.close(); + return Astro.redirect("/app/archive/", 303); + } + + if (intent === "batch_delete") { + const selectedItems = form.getAll("selected_item").map((val) => { + const [type, id] = String(val).split(":", 2); + return { type, id }; + }); + if (selectedItems.length > 0) { + permanentlyDeleteArchivedItems(database, selectedItems); + } + database.close(); + return Astro.redirect("/app/archive/", 303); + } + } catch (cause) { + error = cause instanceof Error ? cause.message : "Action failed."; } - database.close(); - return Astro.redirect("/app/archive/", 303); } -const recipes = (database.prepare("SELECT id, title AS name, deleted_at FROM recipes WHERE deleted_at IS NOT NULL").all() as any[]).map((x) => ({ ...x, type: "recipe" as const })); -const ingredients = (database.prepare("SELECT id, name, deleted_at FROM ingredients WHERE deleted_at IS NOT NULL").all() as any[]).map((x) => ({ ...x, type: "ingredient" as const })); -const books = (database.prepare("SELECT id, name, deleted_at FROM collections WHERE deleted_at IS NOT NULL").all() as any[]).map((x) => ({ ...x, type: "book" as const })); +const recipes = ( + database + .prepare( + "SELECT id, title AS name, deleted_at FROM recipes WHERE deleted_at IS NOT NULL" + ) + .all() as any[] +).map((x) => ({ ...x, type: "recipe" as const })); -const allItems = [...recipes, ...ingredients, ...books].sort((a, b) => a.name.localeCompare(b.name)); +const ingredients = ( + database + .prepare( + "SELECT id, name, deleted_at FROM ingredients WHERE deleted_at IS NOT NULL" + ) + .all() as any[] +).map((x) => ({ ...x, type: "ingredient" as const })); + +const books = ( + database + .prepare( + "SELECT id, name, deleted_at FROM collections WHERE deleted_at IS NOT NULL" + ) + .all() as any[] +).map((x) => ({ ...x, type: "book" as const })); + +const purchases = ( + database + .prepare( + "SELECT id, name, deleted_at FROM purchase_items WHERE deleted_at IS NOT NULL" + ) + .all() as any[] +).map((x) => ({ ...x, type: "purchase" as const })); + +const allItems = [...recipes, ...ingredients, ...books, ...purchases].sort( + (a, b) => a.name.localeCompare(b.name) +); database.close(); const requestedFilter = Astro.url.searchParams.get("type") ?? "all"; @@ -40,9 +112,17 @@ const filteredItems = allItems.filter((item) => { function formatDeleteDate(dateStr: string | null) { if (!dateStr) return ""; try { - const d = new Date(dateStr.includes("Z") || dateStr.includes("T") ? dateStr : `${dateStr.replace(" ", "T")}Z`); + const d = new Date( + dateStr.includes("Z") || dateStr.includes("T") + ? dateStr + : `${dateStr.replace(" ", "T")}Z` + ); if (isNaN(d.getTime())) return dateStr; - return d.toLocaleDateString("en-US", { month: "short", day: "numeric", year: "numeric" }); + return d.toLocaleDateString("en-US", { + month: "short", + day: "numeric", + year: "numeric", + }); } catch { return dateStr; } @@ -54,6 +134,8 @@ function formatDeleteDate(dateStr: string | null) {
+ {error &&
{error}
} +

Archive

- Restore recipes, ingredients, and recipe books removed from the active workspace. + Restore recipes, ingredients, recipe books, and purchase items or permanently purge them.

@@ -89,67 +171,134 @@ function formatDeleteDate(dateStr: string | null) { Recipe books {books.length} + + Purchase items + {purchases.length} + {filteredItems.length > 0 ? ( -
-
- Type - Item Name - Deleted Date - Action +
+ + +
+
+ {!readOnlyMode && ( + + + + )} + Type + Item Name + Deleted Date + Action +
+ +
+ {filteredItems.map((item) => ( +
+ {!readOnlyMode && ( +
+ +
+ )} + +
+ + {item.type === "recipe" && ( + + + + )} + {item.type === "ingredient" && ( + + + + )} + {item.type === "book" && ( + + + + )} + {item.type === "purchase" && ( + + + + )} + +
+ +
+ {titleCase(item.name)} + {item.type} +
+ +
+ Deleted {formatDeleteDate(item.deleted_at)} +
+ +
+ {!readOnlyMode && ( +
+ + +
+ )} +
+
+ ))} +
-
- {filteredItems.map((item) => ( -
-
- - {item.type === "recipe" && ( - - - - )} - {item.type === "ingredient" && ( - - - - )} - {item.type === "book" && ( - - - - )} - -
- -
- {titleCase(item.name)} - {item.type} -
- -
- Deleted {formatDeleteDate(item.deleted_at)} -
- -
- - - - - -
+ + {!readOnlyMode && ( + -
+
+ )} + ) : (
@@ -167,3 +316,137 @@ function formatDeleteDate(dateStr: string | null) {
+ + + + diff --git a/src/application/pages/app/index.astro b/src/application/pages/app/index.astro index e62f6dc..6197024 100644 --- a/src/application/pages/app/index.astro +++ b/src/application/pages/app/index.astro @@ -2,40 +2,50 @@ export const prerender = false; import BaseLayout from "../../../layouts/BaseLayout.astro"; import EntityDirectory from "../../../components/EntityDirectory"; -const TYPE_ICONS:Record<"recipe"|"ingredient"|"book"|"purchase",string> = { +const TYPE_ICONS:Record<"recipe"|"ingredient"|"book"|"purchase"|"inventory",string> = { recipe:"M13.125 0C13.6428 0 14.0625 0.419733 14.0625 0.9375V14.0625C14.0625 14.5803 13.6428 15 13.125 15H0.9375C0.419733 15 0 14.5803 0 14.0625V0.9375C0 0.419733 0.419733 0 0.9375 0H13.125ZM12.1875 1.875H1.875V13.125H12.1875V1.875ZM11.25 9.375V11.25H5.625V9.375H11.25ZM4.6875 9.375V11.25H2.8125V9.375H4.6875ZM11.25 6.5625V8.4375H5.625V6.5625H11.25ZM4.6875 6.5625V8.4375H2.8125V6.5625H4.6875ZM11.25 3.75V5.625H5.625V3.75H11.25ZM4.6875 3.75V5.625H2.8125V3.75H4.6875Z", ingredient:"M7.57975 2.24434L7.65872 2.13161C8.13511 1.47697 8.63143 1.08241 9.16762 0.957718C9.57278 0.863497 9.96486 1.18135 10.0434 1.66766C10.1219 2.15397 9.85705 2.62459 9.45189 2.71881C9.18697 2.78042 8.8382 3.1513 8.44252 3.84957C8.78173 3.85543 9.10757 3.88576 9.42032 3.94078C11.9002 4.37705 13.577 7.45745 12.4331 11.6424C11.496 15.0703 9.68081 16.5977 7.229 15.682C6.85528 15.5611 6.58673 15.5046 6.4542 15.5046C6.40147 15.5046 6.36601 15.5163 6.32134 15.5491L6.15949 15.644C3.74207 16.7473 1.8122 15.2446 0.553846 11.7108C-0.274666 9.38411 -0.146354 7.48777 0.75651 6.03344C1.3667 5.05056 2.12543 4.48603 3.08872 4.01846C3.29048 3.92053 3.51746 3.85185 3.7715 3.81087C3.24172 3.20799 2.94401 2.30051 2.83382 1.12442L2.74286 0.153477L3.63312 0.0415436C5.52154 -0.195889 6.8746 0.590513 7.57975 2.24434ZM3.88234 5.71589C3.2053 6.04451 2.69949 6.42087 2.31601 7.03856C1.73369 7.97655 1.64642 9.26637 2.2885 11.0695C3.21226 13.6636 4.17073 14.4453 5.3377 13.9576C5.67319 13.7379 6.05207 13.6244 6.4542 13.6244C6.81471 13.6244 7.24894 13.7157 7.82562 13.9033C9.09594 14.3772 9.97129 13.6407 10.6554 11.1379C11.5187 7.9797 10.4582 6.03137 9.10636 5.79357C8.42613 5.6739 7.60929 5.71807 6.65655 5.93628L6.446 5.9845L6.2363 5.93257C4.98214 5.62199 4.16221 5.58004 3.88234 5.71589ZM4.69764 1.88235C4.80315 2.16337 4.92885 2.34891 5.06454 2.4469C5.26392 2.5909 5.53003 2.73516 5.86115 2.87726C5.58815 2.34716 5.20687 2.02464 4.69764 1.88235Z", book:"M14.0347 0V16H3.12658C1.50098 16 0.527344 15.0264 0.527344 13.4008V2.59923C0.527344 0.97364 1.50098 0 3.12658 0H14.0347ZM12.1594 12.6763L3.95747 12.6765C2.75227 12.6765 2.40226 12.9098 2.40226 13.4008C2.40226 13.9909 2.53647 14.1251 3.12658 14.1251H12.1598L12.1594 12.6763ZM12.1598 1.87492H3.12658C2.53647 1.87492 2.40226 2.00913 2.40226 2.59923L2.40185 10.9999C2.85223 10.8678 3.37428 10.8015 3.95747 10.8015L12.1594 10.8014L12.1598 1.87492ZM10.0768 3.80157V5.67649H4.45204V3.80157H10.0768Z", - purchase:"M19.5 3.5 18 2l-1.5 1.5L15 2l-1.5 1.5L12 2l-1.5 1.5L9 2 7.5 3.5 6 2 4.5 3.5 3 2v20l1.5-1.5L6 22l1.5-1.5L9 22l1.5-1.5L12 22l1.5-1.5L15 22l1.5-1.5L18 22l1.5-1.5L21 22V2l-1.5 1.5zM19 19.09H5V4.91h14v14.18zM6 15h12v2H6zm0-4h12v2H6zm0-4h12v2H6z" + purchase:"M19.5 3.5 18 2l-1.5 1.5L15 2l-1.5 1.5L12 2l-1.5 1.5L9 2 7.5 3.5 6 2 4.5 3.5 3 2v20l1.5-1.5L6 22l1.5-1.5L9 22l1.5-1.5L12 22l1.5-1.5L15 22l1.5-1.5L18 22l1.5-1.5L21 22V2l-1.5 1.5zM19 19.09H5V4.91h14v14.18zM6 15h12v2H6zm0-4h12v2H6zm0-4h12v2H6z", + inventory:"M19 3H5c-1.1 0-2 .9-2 2v14c0 1.1.9 2 2 2h14c1.1 0 2-.9 2-2V5c0-1.1-.9-2-2-2zm0 16H5V5h14v14zM7 10h2v7H7zm4-3h2v10h-2zm4 6h2v4h-2z" }; -const TYPE_ICON_TRANSFORMS:Record<"recipe"|"ingredient"|"book"|"purchase",string|undefined> = { +const TYPE_ICON_TRANSFORMS:Record<"recipe"|"ingredient"|"book"|"purchase"|"inventory",string|undefined> = { recipe:"scale(1.1, 1.1) translate(4px, 3.5px)", ingredient:"scale(1.3, 1.3) translate(2.5px, 1px)", book:"scale(1.1, 1.1) translate(3.5px, 2.5px)", - purchase:undefined + purchase:undefined, + inventory:undefined }; import { openDatabase } from "../../../lib/database"; import { readOnlyMode } from "../../../lib/runtime"; import { titleCase } from "../../../lib/format"; +import type { + DirectoryBookRow, + DirectoryIngredientRow, + DirectoryPurchaseRow, + DirectoryRecipeRow, +} from "../../../lib/repository"; const database = openDatabase(); -if (!database) return new Response("Database unavailable", { status:503 }); -const recipes=database.prepare(`SELECT r.id,r.title,r.yield_quantity,r.yield_unit_id, +if (!database) return new Response("Database unavailable", { status: 503 }); +const recipes = database.prepare(`SELECT r.id,r.title,r.yield_quantity,r.yield_unit_id, (SELECT count(*) FROM recipe_items ri WHERE ri.recipe_id=r.id) item_count, (SELECT count(*) FROM recipe_steps rs WHERE rs.recipe_id=r.id AND rs.instruction LIKE 'TODO:%') placeholder_count - FROM recipes r WHERE r.deleted_at IS NULL ORDER BY r.title`).all() as any[]; -const ingredients=database.prepare(`SELECT i.id,i.name,i.status, + FROM recipes r WHERE r.deleted_at IS NULL ORDER BY r.title`).all() as unknown as DirectoryRecipeRow[]; +const ingredients = database.prepare(`SELECT i.id,i.name,i.status, (SELECT count(*) FROM recipe_items r WHERE r.ingredient_id=i.id) recipe_count, (SELECT count(*) FROM price_observations po JOIN purchase_items p ON p.id=po.purchase_item_id WHERE p.ingredient_id=i.id) price_count, (SELECT count(*) FROM source_mappings m WHERE m.subject_type='ingredient' AND m.subject_id=i.id AND m.mapping_type='nutrition' AND m.status='reviewed') nutrition_count - FROM ingredients i WHERE i.deleted_at IS NULL ORDER BY i.name`).all() as any[]; -const books=database.prepare("SELECT c.id,c.name,c.description,(SELECT count(*) FROM collection_recipes r WHERE r.collection_id=c.id) recipe_count FROM collections c WHERE c.deleted_at IS NULL ORDER BY c.name").all() as any[]; -const purchases=database.prepare(`SELECT p.id,p.ingredient_id,p.name,p.supplier_id,p.status,i.name ingredient_name,p.package_quantity,p.package_unit_id, + FROM ingredients i WHERE i.deleted_at IS NULL ORDER BY i.name`).all() as unknown as DirectoryIngredientRow[]; +const books = database.prepare("SELECT c.id,c.name,c.description,(SELECT count(*) FROM collection_recipes r WHERE r.collection_id=c.id) recipe_count FROM collections c WHERE c.deleted_at IS NULL ORDER BY c.name").all() as unknown as DirectoryBookRow[]; +const purchases = database.prepare(`SELECT p.id,p.ingredient_id,p.name,p.supplier_id,p.status,i.name ingredient_name,p.package_quantity,p.package_unit_id, (SELECT amount FROM price_observations x WHERE x.purchase_item_id=p.id ORDER BY effective_at DESC LIMIT 1) latest_price - FROM purchase_items p JOIN ingredients i ON i.id=p.ingredient_id ORDER BY p.name`).all() as any[]; + FROM purchase_items p JOIN ingredients i ON i.id=p.ingredient_id ORDER BY p.name`).all() as unknown as DirectoryPurchaseRow[]; +const inventoryCounts = database.prepare("SELECT count(*) as c FROM inventory_counts WHERE deleted_at IS NULL").get() as { c: number } | undefined; database.close(); const requested=Astro.url.searchParams.get("type"); +if (requested === "inventory") return Astro.redirect("/app/inventory/", 303); const type=["recipe","ingredient","book","purchase"].includes(requested??"")?requested:undefined; const query=(Astro.url.searchParams.get("q")??"").trim(); const normalizedQuery=query.toLocaleLowerCase(); @@ -47,11 +57,12 @@ const filtering=attention||missingCost||noUsda||unused||emptyRecipe||placeholder const filteredIngredients=ingredients.filter((ingredient)=>{if(!filtering)return true;const selected=[missingCost&&ingredient.price_count===0,noUsda&&ingredient.nutrition_count===0,unused&&ingredient.recipe_count===0].filter(Boolean);return missingCost||noUsda||unused?selected.length>0:ingredient.price_count===0||ingredient.nutrition_count===0||ingredient.recipe_count===0;}); const filteredRecipes=recipes.filter(recipe=>!filtering||(emptyRecipe&&recipe.item_count===0)||(placeholderSteps&&recipe.placeholder_count>0)||(!emptyRecipe&&!placeholderSteps&&(recipe.item_count===0||recipe.placeholder_count>0))); const FILTER_ICON_PATH="M18 6.0201C18 4.81608 17.1873 3.79266 16.0736 3.46156L16.0736 0.722412C16.0736 0.301005 15.7425 -9.86801e-08 15.3211 -1.171e-07C14.8996 -1.35521e-07 14.5685 0.331105 14.5685 0.752512L14.5685 3.49166C13.4548 3.79266 12.6421 4.84618 12.6421 6.0502C12.6421 7.28432 13.4548 8.30774 14.5685 8.63884L14.5685 19.7459C14.5685 20.1673 14.8996 20.4984 15.3211 20.4984C15.7425 20.4984 16.0736 20.1673 16.0736 19.7459L16.0736 8.63884C17.1873 8.27764 18 7.25422 18 6.0201ZM16.495 6.0502C16.495 6.68231 15.9833 7.22412 15.3211 7.22412C14.6588 7.22412 14.1471 6.68231 14.1471 6.0502C14.1471 5.41809 14.6588 4.87628 15.3211 4.87628C15.9833 4.87628 16.495 5.41809 16.495 6.0502Z"; -const tabs:Array<{type:string;label:string;count:number;kind:"recipe"|"ingredient"|"book"|"purchase"}>=[ +const tabs:Array<{type:string;label:string;count:number;kind:"recipe"|"ingredient"|"book"|"purchase"|"inventory";href?:string}>=[ {type:"recipe",label:"Recipes",count:recipes.length,kind:"recipe"}, {type:"ingredient",label:"Ingredients",count:ingredients.length,kind:"ingredient"}, {type:"book",label:"Recipe books",count:books.length,kind:"book"}, {type:"purchase",label:"Purchase items",count:purchases.length,kind:"purchase"}, + {type:"inventory",label:"Inventory",count:inventoryCounts?.c ?? 0,kind:"inventory",href:"/app/inventory/"}, ]; const allSearchResults=normalizedQuery ? [ ...recipes.filter((item)=>`${item.title} ${item.id}`.toLocaleLowerCase().includes(normalizedQuery)).map((item)=>({id:item.id,kind:"recipe" as const,label:"Recipe",name:item.title,detail:`${item.yield_quantity} ${item.yield_unit_id}`,href:`/app/recipes/${item.id}/`})), @@ -95,7 +106,7 @@ const purchaseRows=purchases.map(item=>({id:item.id,name:item.name,href:`/app/in