Merge remote-tracking branch 'origin/main' into demo

This commit is contained in:
Christopher C. Wells 2021-04-18 14:45:49 -07:00
commit 7aec69651d
36 changed files with 661 additions and 323 deletions

View File

@ -3,7 +3,7 @@ APP_NAME=kcal
APP_ENV=local
APP_KEY=
APP_DEBUG=true
APP_URL=http://kcal.test
APP_URL=http://127.0.0.1
APP_PORT=8080
APP_SERVICE=app
APP_TIMEZONE=UTC
@ -12,7 +12,7 @@ LOG_CHANNEL=stack
LOG_LEVEL=debug
DB_CONNECTION=mysql
DB_HOST=mysql
DB_HOST=db
DB_PORT=3306
DB_DATABASE=kcal
DB_USERNAME=kcal

View File

@ -2,13 +2,12 @@
namespace App\Http\Controllers;
use App\Http\Requests\UpdateFoodRequest;
use App\Models\Food;
use App\Rules\StringIsDecimalOrFraction;
use App\Support\Number;
use App\Support\Nutrients;
use Illuminate\Contracts\View\View;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Collection;
use Illuminate\Support\Str;
@ -35,7 +34,7 @@ class FoodController extends Controller
/**
* Store a newly created resource in storage.
*/
public function store(Request $request): RedirectResponse
public function store(UpdateFoodRequest $request): RedirectResponse
{
return $this->update($request, new Food());
}
@ -72,25 +71,10 @@ class FoodController extends Controller
/**
* Update the specified resource in storage.
*/
public function update(Request $request, Food $food): RedirectResponse
public function update(UpdateFoodRequest $request, Food $food): RedirectResponse
{
$attributes = $request->validate([
'name' => 'required|string',
'detail' => 'nullable|string',
'brand' => 'nullable|string',
'source' => 'nullable|string',
'notes' => 'nullable|string',
'serving_size' => ['required', new StringIsDecimalOrFraction],
'serving_unit' => 'nullable|string',
'serving_unit_name' => 'nullable|string',
'serving_weight' => 'required|numeric',
'calories' => 'nullable|numeric',
'fat' => 'nullable|numeric',
'cholesterol' => 'nullable|numeric',
'sodium' => 'nullable|numeric',
'carbohydrates' => 'nullable|numeric',
'protein' => 'nullable|numeric',
]);
$attributes = $request->validated();
$attributes['serving_size'] = Number::floatFromString($attributes['serving_size']);
$attributes['name'] = Str::lower($attributes['name']);

View File

@ -2,6 +2,7 @@
namespace App\Http\Controllers;
use App\Http\Requests\UpdateGoalRequest;
use App\Models\Goal;
use Illuminate\Contracts\View\View;
use Illuminate\Http\RedirectResponse;
@ -40,7 +41,7 @@ class GoalController extends Controller
/**
* Store a newly created resource in storage.
*/
public function store(Request $request): RedirectResponse
public function store(UpdateGoalRequest $request): RedirectResponse
{
return $this->update($request, new Goal());
}
@ -70,15 +71,9 @@ class GoalController extends Controller
/**
* Update the specified resource in storage.
*/
public function update(Request $request, Goal $goal): RedirectResponse
public function update(UpdateGoalRequest $request, Goal $goal): RedirectResponse
{
$attributes = $request->validate([
'from' => ['nullable', 'date'],
'to' => ['nullable', 'date'],
'frequency' => ['required', 'string'],
'name' => ['required', 'string'],
'goal' => ['required', 'numeric'],
]);
$attributes = $request->validated();
$goal->fill($attributes)->user()->associate(Auth::user());
$goal->save();
session()->flash('message', "Goal updated!");

View File

@ -5,13 +5,11 @@
namespace App\Http\Controllers;
use App\Http\Requests\StoreFromNutrientsJournalEntryRequest;
use App\Http\Requests\StoreJournalEntryRequest;
use App\Models\Food;
use App\Models\JournalEntry;
use App\Models\Recipe;
use App\Rules\ArrayNotEmpty;
use App\Rules\InArray;
use App\Rules\StringIsDecimalOrFraction;
use App\Rules\UsesIngredientTrait;
use App\Support\ArrayFormat;
use App\Support\Number;
use App\Support\Nutrients;
@ -85,6 +83,7 @@ class JournalEntryController extends Controller
continue;
}
$ingredients[$key] = [
'key' => $key,
'date' => $old['date'][$key],
'meal' => $old['meal'][$key],
'amount' => $amount,
@ -130,28 +129,9 @@ class JournalEntryController extends Controller
/**
* Store a newly created resource in storage.
*/
public function store(Request $request): RedirectResponse
public function store(StoreJournalEntryRequest $request): RedirectResponse
{
$input = $request->validate([
'ingredients.date' => ['required', 'array', new ArrayNotEmpty],
'ingredients.date.*' => ['nullable', 'date', 'required_with:ingredients.id.*'],
'ingredients.meal' => ['required', 'array', new ArrayNotEmpty],
'ingredients.meal.*' => [
'nullable',
'string',
'required_with:ingredients.id.*',
new InArray(JournalEntry::meals()->pluck('value')->toArray())
],
'ingredients.amount' => ['required', 'array', new ArrayNotEmpty],
'ingredients.amount.*' => ['required_with:ingredients.id.*', 'nullable', new StringIsDecimalOrFraction],
'ingredients.unit' => ['required', 'array'],
'ingredients.unit.*' => ['required_with:ingredients.id.*'],
'ingredients.id' => ['required', 'array', new ArrayNotEmpty],
'ingredients.id.*' => 'required_with:ingredients.amount.*|nullable',
'ingredients.type' => ['required', 'array', new ArrayNotEmpty],
'ingredients.type.*' => ['required_with:ingredients.id.*', 'nullable', new UsesIngredientTrait()],
'group_entries' => ['nullable', 'boolean'],
]);
$input = $request->validated();
$ingredients = ArrayFormat::flipTwoDimensionalKeys($input['ingredients']);
@ -284,23 +264,8 @@ class JournalEntryController extends Controller
/**
* Store an entry from nutrients.
*/
public function storeFromNutrients(Request $request): RedirectResponse {
$attributes = $request->validate([
'date' => ['required', 'date'],
'meal' => [
'required',
'string',
new InArray(JournalEntry::meals()->pluck('value')->toArray())
],
'summary' => ['required', 'string'],
'calories' => ['nullable', 'required_without_all:fat,cholesterol,sodium,carbohydrates,protein', 'numeric'],
'fat' => ['nullable', 'required_without_all:calories,cholesterol,sodium,carbohydrates,protein', 'numeric'],
'cholesterol' => ['nullable', 'required_without_all:calories,fat,sodium,carbohydrates,protein', 'numeric'],
'sodium' => ['nullable', 'required_without_all:calories,fat,cholesterol,carbohydrates,protein', 'numeric'],
'carbohydrates' => ['nullable', 'required_without_all:calories,fat,cholesterol,sodium,protein', 'numeric'],
'protein' => ['nullable', 'required_without_all:calories,fat,cholesterol,sodium,carbohydrates', 'numeric'],
]);
public function storeFromNutrients(StoreFromNutrientsJournalEntryRequest $request): RedirectResponse {
$attributes = $request->validated();
$entry = JournalEntry::make(array_filter($attributes))
->user()->associate(Auth::user());
$entry->save();

View File

@ -2,19 +2,16 @@
namespace App\Http\Controllers;
use App\Http\Requests\UpdateRecipeRequest;
use App\Models\Food;
use App\Models\IngredientAmount;
use App\Models\Recipe;
use App\Models\RecipeSeparator;
use App\Models\RecipeStep;
use App\Rules\ArrayNotEmpty;
use App\Rules\StringIsDecimalOrFraction;
use App\Rules\UsesIngredientTrait;
use App\Support\Number;
use App\Support\Nutrients;
use Illuminate\Contracts\View\View;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Str;
@ -44,12 +41,9 @@ class RecipeController extends Controller
/**
* Store a newly created resource in storage.
*
* @param \Illuminate\Http\Request $request
* @return \Illuminate\Http\RedirectResponse
*
* @throws \Throwable
*/
public function store(Request $request): RedirectResponse
public function store(UpdateRecipeRequest $request): RedirectResponse
{
return $this->update($request, new Recipe());
}
@ -96,18 +90,18 @@ class RecipeController extends Controller
'ingredient_name' => $old['name'][$key],
'detail' => $old['detail'][$key],
];
}
// Add supported units for the ingredient.
$ingredient = NULL;
if ($ingredients[$key]['ingredient_type'] === Food::class) {
$ingredient = Food::whereId($ingredients[$key]['ingredient_id'])->first();
}
elseif ($ingredients[$key]['ingredient_type'] === Recipe::class) {
$ingredient = Recipe::whereId($ingredients[$key]['ingredient_id'])->first();
}
if ($ingredient) {
$ingredients[$key]['units_supported'] = $ingredient->units_supported;
// Add supported units for the ingredient.
$ingredient = NULL;
if ($ingredients[$key]['ingredient_type'] === Food::class) {
$ingredient = Food::whereId($ingredients[$key]['ingredient_id'])->first();
}
elseif ($ingredients[$key]['ingredient_type'] === Recipe::class) {
$ingredient = Recipe::whereId($ingredients[$key]['ingredient_id'])->first();
}
if ($ingredient) {
$ingredients[$key]['units_supported'] = $ingredient->units_supported;
}
}
}
else {
@ -187,50 +181,11 @@ class RecipeController extends Controller
/**
* Update the specified resource in storage.
*
* @param \Illuminate\Http\Request $request
* @param \App\Models\Recipe $recipe
*
* @return \Illuminate\Http\RedirectResponse
*
* @throws \Throwable
*/
public function update(Request $request, Recipe $recipe): RedirectResponse
public function update(UpdateRecipeRequest $request, Recipe $recipe): RedirectResponse
{
$input = $request->validate([
'name' => ['required', 'string'],
'description' => ['nullable', 'string'],
'description_delta' => ['nullable', 'string'],
'image' => ['nullable', 'file', 'mimes:jpg,png,gif'],
'remove_image' => ['nullable', 'boolean'],
'servings' => ['required', 'numeric'],
'time_prep' => ['nullable', 'numeric'],
'time_cook' => ['nullable', 'numeric'],
'weight' => ['nullable', 'numeric'],
'source' => ['nullable', 'string'],
'ingredients.amount' => ['required', 'array', new ArrayNotEmpty],
'ingredients.amount.*' => ['required_with:ingredients.id.*', 'nullable', new StringIsDecimalOrFraction],
'ingredients.unit' => ['required', 'array'],
'ingredients.unit.*' => ['required_with:ingredients.id.*'],
'ingredients.detail' => ['required', 'array'],
'ingredients.detail.*' => ['nullable', 'string'],
'ingredients.id' => ['required', 'array', new ArrayNotEmpty],
'ingredients.id.*' => 'required_with:ingredients.amount.*|nullable',
'ingredients.type' => ['required', 'array', new ArrayNotEmpty],
'ingredients.type.*' => ['required_with:ingredients.id.*', 'nullable', new UsesIngredientTrait()],
'ingredients.key' => ['nullable', 'array'],
'ingredients.key.*' => ['nullable', 'int'],
'ingredients.weight' => ['required', 'array', new ArrayNotEmpty],
'ingredients.weight.*' => ['required', 'int'],
'separators.key' => ['nullable', 'array'],
'separators.key.*' => ['nullable', 'int'],
'separators.weight' => ['nullable', 'array'],
'separators.weight.*' => ['required', 'int'],
'separators.text' => ['nullable', 'array'],
'separators.text.*' => ['nullable', 'string'],
'steps.step' => ['required', 'array', new ArrayNotEmpty],
'steps.step.*' => ['nullable', 'string'],
'steps.key' => ['nullable', 'array'],
]);
$input = $request->validated();
// Validate that no ingredients are recursive.
// TODO: refactor as custom validator.
@ -246,6 +201,7 @@ class RecipeController extends Controller
'description_delta' => $input['description_delta'],
'servings' => (int) $input['servings'],
'weight' => $input['weight'],
'volume' => Number::floatFromString($input['volume']),
'time_prep' => (int) $input['time_prep'],
'time_cook' => (int) $input['time_cook'],
'source' => $input['source'],
@ -285,7 +241,7 @@ class RecipeController extends Controller
->usingFileName("{$recipe->slug}.{$file->extension()}")
->toMediaCollection();
}
elseif (isset($input['remove_image']) && (bool) $input['remove_image']) {
elseif (isset($input['remove_image']) && $input['remove_image']) {
$recipe->clearMediaCollection();
}

View File

@ -0,0 +1,36 @@
<?php
namespace App\Http\Requests;
use App\Models\JournalEntry;
use App\Rules\ArrayNotEmpty;
use App\Rules\InArray;
use App\Rules\StringIsPositiveDecimalOrFraction;
use App\Rules\UsesIngredientTrait;
use Illuminate\Foundation\Http\FormRequest;
class StoreFromNutrientsJournalEntryRequest extends FormRequest
{
/**
* Get the validation rules that apply to the request.
*/
public function rules(): array
{
return [
'date' => ['required', 'date'],
'meal' => [
'required',
'string',
new InArray(JournalEntry::meals()->pluck('value')->toArray())
],
'summary' => ['required', 'string'],
'calories' => ['nullable', 'numeric', 'min:0', 'required_without_all:fat,cholesterol,sodium,carbohydrates,protein'],
'fat' => ['nullable', 'numeric', 'min:0', 'required_without_all:calories,cholesterol,sodium,carbohydrates,protein'],
'cholesterol' => ['nullable', 'numeric', 'min:0', 'required_without_all:calories,fat,sodium,carbohydrates,protein'],
'sodium' => ['nullable', 'numeric', 'min:0', 'required_without_all:calories,fat,cholesterol,carbohydrates,protein'],
'carbohydrates' => ['nullable', 'numeric', 'min:0', 'required_without_all:calories,fat,cholesterol,sodium,protein'],
'protein' => ['nullable', 'numeric', 'min:0', 'required_without_all:calories,fat,cholesterol,sodium,carbohydrates'],
];
}
}

View File

@ -0,0 +1,57 @@
<?php
namespace App\Http\Requests;
use App\Models\JournalEntry;
use App\Rules\ArrayNotEmpty;
use App\Rules\InArray;
use App\Rules\StringIsPositiveDecimalOrFraction;
use App\Rules\UsesIngredientTrait;
use Illuminate\Foundation\Http\FormRequest;
class StoreJournalEntryRequest extends FormRequest
{
/**
* Get the validation rules that apply to the request.
*/
public function rules(): array
{
return [
'ingredients.date' => ['required', 'array', new ArrayNotEmpty],
'ingredients.date.*' => ['nullable', 'date', 'required_with:ingredients.id.*'],
'ingredients.meal' => ['required', 'array', new ArrayNotEmpty],
'ingredients.meal.*' => [
'nullable',
'string',
'required_with:ingredients.id.*',
new InArray(JournalEntry::meals()->pluck('value')->toArray())
],
'ingredients.amount' => ['required', 'array', new ArrayNotEmpty],
'ingredients.amount.*' => ['required_with:ingredients.id.*', 'nullable', new StringIsPositiveDecimalOrFraction],
'ingredients.unit' => ['required', 'array'],
'ingredients.unit.*' => ['required_with:ingredients.id.*'],
'ingredients.id.*' => 'required_with:ingredients.amount.*|nullable',
'ingredients.type.*' => ['required_with:ingredients.id.*', 'nullable', new UsesIngredientTrait()],
'group_entries' => ['nullable', 'boolean'],
];
}
/**
* @inheritdoc
*/
public function attributes(): array
{
return [
'ingredients.amount' => 'amount',
'ingredients.amount.*' => 'amount',
'ingredients.date' => 'date',
'ingredients.date.*' => 'date',
'ingredients.id.*' => 'item',
'ingredients.meal' => 'meal',
'ingredients.meal.*' => 'meal',
'ingredients.unit' => 'unit',
'ingredients.unit.*' => 'unit',
];
}
}

View File

@ -0,0 +1,35 @@
<?php
namespace App\Http\Requests;
use App\Rules\StringIsPositiveDecimalOrFraction;
use Illuminate\Foundation\Http\FormRequest;
class UpdateFoodRequest extends FormRequest
{
/**
* Get the validation rules that apply to the request.
*/
public function rules(): array
{
return [
'name' => ['required', 'string'],
'detail' => ['nullable', 'string'],
'brand' => ['nullable', 'string'],
'source' => ['nullable', 'string'],
'notes' => ['nullable', 'string'],
'serving_size' => ['required', new StringIsPositiveDecimalOrFraction],
'serving_unit' => ['nullable', 'string'],
'serving_unit_name' => ['nullable', 'string'],
'serving_weight' => ['required', 'numeric', 'min:0'],
'calories' => ['nullable', 'numeric', 'min:0'],
'fat' => ['nullable', 'numeric', 'min:0'],
'cholesterol' => ['nullable', 'numeric', 'min:0'],
'sodium' => ['nullable', 'numeric', 'min:0'],
'carbohydrates' => ['nullable', 'numeric', 'min:0'],
'protein' => ['nullable', 'numeric', 'min:0'],
];
}
}

View File

@ -0,0 +1,25 @@
<?php
namespace App\Http\Requests;
use App\Rules\StringIsPositiveDecimalOrFraction;
use Illuminate\Foundation\Http\FormRequest;
class UpdateGoalRequest extends FormRequest
{
/**
* Get the validation rules that apply to the request.
*/
public function rules(): array
{
return [
'from' => ['nullable', 'date'],
'to' => ['nullable', 'date'],
'frequency' => ['required', 'string'],
'name' => ['required', 'string'],
'goal' => ['required', 'numeric', 'min:0'],
];
}
}

View File

@ -0,0 +1,80 @@
<?php
namespace App\Http\Requests;
use App\Rules\ArrayNotEmpty;
use App\Rules\StringIsPositiveDecimalOrFraction;
use App\Rules\UsesIngredientTrait;
use Illuminate\Foundation\Http\FormRequest;
class UpdateRecipeRequest extends FormRequest
{
/**
* Get the validation rules that apply to the request.
*/
public function rules(): array
{
return [
'name' => ['required', 'string'],
'description' => ['nullable', 'string'],
'description_delta' => ['nullable', 'string'],
'image' => ['nullable', 'file', 'mimes:jpg,png,gif'],
'remove_image' => ['nullable', 'boolean'],
'servings' => ['required', 'numeric'],
'time_prep' => ['nullable', 'numeric'],
'time_cook' => ['nullable', 'numeric'],
'weight' => ['nullable', 'numeric', 'min:0'],
'volume' => ['nullable', new StringIsPositiveDecimalOrFraction],
'source' => ['nullable', 'string'],
'ingredients.amount' => ['required', 'array', new ArrayNotEmpty],
'ingredients.amount.*' => ['required_with:ingredients.id.*', 'nullable', new StringIsPositiveDecimalOrFraction],
'ingredients.unit' => ['required', 'array'],
'ingredients.unit.*' => ['required_with:ingredients.id.*'],
'ingredients.detail' => ['required', 'array'],
'ingredients.detail.*' => ['nullable', 'string'],
'ingredients.id' => ['required', 'array', new ArrayNotEmpty],
'ingredients.id.*' => ['required_with:ingredients.amount.*', 'required_with:ingredients.unit.*', 'nullable'],
'ingredients.type' => ['required', 'array', new ArrayNotEmpty],
'ingredients.type.*' => ['required_with:ingredients.id.*', 'nullable', new UsesIngredientTrait()],
'ingredients.key' => ['nullable', 'array'],
'ingredients.key.*' => ['nullable', 'int'],
'ingredients.weight' => ['required', 'array', new ArrayNotEmpty],
'ingredients.weight.*' => ['required', 'int'],
'separators.key' => ['nullable', 'array'],
'separators.key.*' => ['nullable', 'int'],
'separators.weight' => ['nullable', 'array'],
'separators.weight.*' => ['required', 'int'],
'separators.text' => ['nullable', 'array'],
'separators.text.*' => ['nullable', 'string'],
'steps.step' => ['required', 'array', new ArrayNotEmpty],
'steps.step.*' => ['nullable', 'string'],
'steps.key' => ['nullable', 'array'],
];
}
/**
* @inheritdoc
*/
public function messages(): array
{
return [
'ingredients.id.*.required_with' => 'Missing :attribute in Ingredients.',
'ingredients.amount.*.required_with' => 'Missing :attribute in Ingredients.',
'ingredients.unit.*.required_with' => 'Missing :attribute in Ingredients.',
];
}
/**
* @inheritdoc
*/
public function attributes(): array
{
return [
'ingredients.id.*' => 'ingredient name',
'ingredients.amount.*' => 'ingredient amount',
'ingredients.unit.*' => 'ingredient unit',
];
}
}

View File

@ -36,6 +36,8 @@ class RecipeSchema extends SchemaProvider
'servings' => $resource->servings,
'weight' => $resource->weight,
'serving_weight' => $resource->serving_weight,
'volume' => $resource->volume,
'volumeFormatted' => $resource->volume_formatted,
'units_supported' => $resource->units_supported->pluck('label'),
'caloriesPerServing' => $resource->caloriesPerServing(),
'carbohydratesPerServing' => $resource->carbohydratesPerServing(),

View File

@ -6,6 +6,7 @@ use App\Support\Nutrients;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Support\Str;
/**
* App\Models\Goal
@ -44,7 +45,10 @@ final class Goal extends Model
* Supported options for thr frequency attribute.
*/
public static array $frequencyOptions = [
'daily' => ['value' => 'daily', 'label' => 'daily'],
'daily' => [
'value' => 'daily',
'label' => 'daily'
],
];
/**
@ -94,7 +98,7 @@ final class Goal extends Model
foreach (Nutrients::all() as $nutrient) {
$options[$nutrient['value']] = [
'value' => $nutrient['value'],
'label' => $nutrient['label'],
'label' => Str::ucfirst($nutrient['label']),
'unit' => $nutrient['unit'],
];
}

View File

@ -7,6 +7,7 @@ use App\Models\Traits\Ingredient;
use App\Models\Traits\Journalable;
use App\Models\Traits\Sluggable;
use App\Models\Traits\Taggable;
use App\Support\Number;
use App\Support\Nutrients;
use ElasticScoutDriverPlus\QueryDsl;
use Illuminate\Database\Eloquent\Factories\HasFactory;
@ -79,6 +80,9 @@ use Spatie\MediaLibrary\MediaCollections\Models\Media;
* @property-read \Illuminate\Database\Eloquent\Collection|\App\Models\RecipeSeparator[] $separators
* @property-read int|null $separators_count
* @property-read Collection $units_supported
* @property float|null $volume
* @property-read string|null $volume_formatted
* @method static \Illuminate\Database\Eloquent\Builder|Recipe whereVolume($value)
*/
final class Recipe extends Model implements HasMedia
{
@ -104,6 +108,7 @@ final class Recipe extends Model implements HasMedia
'source',
'servings',
'weight',
'volume',
];
/**
@ -114,6 +119,7 @@ final class Recipe extends Model implements HasMedia
'time_prep' => 'int',
'time_cook' => 'int',
'weight' => 'float',
'volume' => 'float',
];
/**
@ -133,6 +139,7 @@ final class Recipe extends Model implements HasMedia
*/
protected $appends = [
'serving_weight',
'volume_formatted',
'time_total',
'units_supported'
];
@ -169,6 +176,17 @@ final class Recipe extends Model implements HasMedia
return round($this->weight / $this->servings);
}
/**
* Get the volume as a formatted string (e.g. 0.5 = 1/2).
*/
public function getVolumeFormattedAttribute(): ?string {
$result = null;
if (!empty($this->volume)) {
$result = Number::rationalStringFromFloat($this->volume);
}
return $result;
}
/**
* Get the ingredients list (ingredient amounts and separators).
*/

View File

@ -65,6 +65,9 @@ trait Ingredient
if (!empty($this->serving_weight)) {
$supported = $supported->merge($units->where('type', 'weight'));
}
if (isset($this->volume) && !empty($this->volume)) {
$supported = $supported->merge($units->where('type', 'volume'));
}
return $supported->sortBy('label');
}
}

View File

@ -5,7 +5,7 @@ namespace App\Rules;
use App\Support\Number;
use Illuminate\Contracts\Validation\Rule;
class StringIsDecimalOrFraction implements Rule
class StringIsPositiveDecimalOrFraction implements Rule
{
/**
* {@inheritdoc}
@ -14,7 +14,7 @@ class StringIsDecimalOrFraction implements Rule
{
try {
$result = Number::floatFromString($value);
return $result != 0;
return $result > 0;
}
catch (\InvalidArgumentException $e) {
// Allow to pass through, method will return false.
@ -27,6 +27,6 @@ class StringIsDecimalOrFraction implements Rule
*/
public function message(): string
{
return 'The :attribute must be a decimal or fraction.';
return 'The :attribute must be a positive decimal or fraction.';
}
}

View File

@ -175,6 +175,8 @@ class Nutrients
/**
* Calculate a nutrient amount for a recipe.
*
* Weight base unit is grams, volume base unit is cups.
*/
public static function calculateRecipeNutrientAmount(
Recipe $recipe,
@ -182,18 +184,19 @@ class Nutrients
float $amount,
string $fromUnit
): float {
if ($fromUnit === 'oz') {
return $amount * self::$gramsPerOunce / $recipe->weight * $recipe->{"{$nutrient}Total"}();
}
elseif ($fromUnit === 'serving') {
if ($fromUnit === 'serving') {
// Use "per serving" methods directly.
return $recipe->{"{$nutrient}PerServing"}() * $amount;
}
elseif ($fromUnit === 'gram') {
return $amount / $recipe->weight * $recipe->{"{$nutrient}Total"}();
}
else {
throw new \DomainException("Unsupported recipe unit: {$fromUnit}");
}
$multiplier = match ($fromUnit) {
'oz' => $amount * self::$gramsPerOunce / $recipe->weight,
'gram' => $amount / $recipe->weight,
'tsp' => $amount / 48 / $recipe->volume,
'tbsp' => $amount / 16 / $recipe->volume,
'cup' => $amount / $recipe->volume,
default => throw new \DomainException("Unsupported recipe unit: {$fromUnit}"),
};
return $multiplier * $recipe->{"{$nutrient}Total"}();
}
/**

View File

@ -8,20 +8,20 @@ use Illuminate\View\Component;
class Select extends Component
{
public ?bool $hasError;
public Collection|array $options;
public ?string $selectedValue;
/**
* Select constructor.
*
* @param \Illuminate\Support\Collection|array $options
* @param ?string $selectedValue
*/
public function __construct(
Collection|array $options,
?bool $hasError = false,
?string $selectedValue = '',
) {
$this->options = $options;
$this->hasError = $hasError;
$this->selectedValue = $selectedValue;
}
@ -29,6 +29,7 @@ class Select extends Component
{
return view('components.inputs.select')
->with('options', $this->options)
->with('hasError', $this->hasError)
->with('selectedValue', $this->selectedValue);
}

View File

@ -23,6 +23,7 @@ class RecipeFactory extends Factory
public function definition(): array
{
$description = htmlspecialchars($this->faker->realText(500));
$volumes = [1/4, 1/3, 1/2, 2/3, 3/4, 1, 1 + 1/2, 1 + 3/4, 2, 2 + 1/2, 3, 3 + 1/2, 4, 5];
return [
'name' => Words::randomWords(Arr::random(['npan', 'npn', 'anpn'])),
'description' => "<p>{$description}</p>",
@ -31,7 +32,8 @@ class RecipeFactory extends Factory
'time_cook' => $this->faker->numberBetween(0, 90),
'source' => $this->faker->optional()->url,
'servings' => $this->faker->numberBetween(1, 10),
'weight' => $this->faker->randomFloat(1, 60, 2000),
'weight' => $this->faker->optional()->randomFloat(1, 60, 2000),
'volume' => $this->faker->optional()->randomElement($volumes),
'tags' => Words::randomWords(Arr::random(['a', 'aa', 'aaa']), TRUE),
];
}

View File

@ -24,6 +24,7 @@ class CreateRecipesTable extends Migration
$table->string('source')->nullable();
$table->unsignedInteger('servings');
$table->unsignedFloat('weight')->nullable();
//$table->decimal('volume', 10, 8)->unsigned()->nullable();
$table->timestamps();
});
}

View File

@ -0,0 +1,32 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
class AddVolumeToRecipes extends Migration
{
/**
* Run the migrations.
*
* @return void
*/
public function up()
{
Schema::table('recipes', function (Blueprint $table) {
$table->decimal('volume', 10, 8)->unsigned()->nullable();
});
}
/**
* Reverse the migrations.
*
* @return void
*/
public function down()
{
Schema::table('recipes', function (Blueprint $table) {
$table->dropColumn('volume');
});
}
}

View File

@ -39,7 +39,7 @@ services:
image: phpmyadmin
restart: always
ports:
- 8080:80
- 8081:80
environment:
PMA_HOST: db
MYSQL_ROOT_PASSWORD: '${DB_PASSWORD:-kcal}'

2
public/css/app.css vendored

File diff suppressed because one or more lines are too long

View File

@ -1,3 +1,5 @@
@props(['hasError' => false])
<div x-data="picker()">
<div>
<div>
@ -11,7 +13,7 @@
x-ref="ingredients_type"/>
<x-inputs.input type="text"
name="ingredients[name][]"
class="w-full"
class="w-full{{ $hasError ? ' border-red-600' : '' }}"
value="{{ $defaultName ?? '' }}"
placeholder="Search..."
autocomplete="off"

View File

@ -1,3 +1,20 @@
@props(['disabled' => false])
@props(['disabled' => false, 'hasError' => false])
<input {{ $disabled ? 'disabled' : '' }} {!! $attributes->merge(['class' => 'rounded-md shadow-sm border-gray-300 focus:border-indigo-300 focus:ring focus:ring-indigo-200 focus:ring-opacity-50']) !!}>
@php
$classes = [
'rounded-md',
'shadow-sm',
'border-gray-300',
'focus:border-indigo-300',
'focus:ring',
'focus:ring-indigo-200',
'focus:ring-opacity-50'
];
if ($hasError) {
$classes[] = 'border-red-600';
}
@endphp
<input
{{ $disabled ? 'disabled' : '' }}
{!! $attributes->merge(['class' => implode(' ', $classes)]) !!}>

View File

@ -1,4 +1,23 @@
<select {{ $attributes->merge(['class' => 'rounded-md shadow-sm border-gray-300 focus:border-indigo-300 focus:ring focus:ring-indigo-200 focus:ring-opacity-50']) }}>
@props(['disabled' => false, 'hasError' => false])
@php
$classes = [
'rounded-md',
'shadow-sm',
'border-gray-300',
'focus:border-indigo-300',
'focus:ring',
'focus:ring-indigo-200',
'focus:ring-opacity-50',
];
if ($hasError) {
$classes[] = 'border-red-600';
}
@endphp
<select
{{ $disabled ? 'disabled' : '' }}
{!! $attributes->merge(['class' => implode(' ', $classes)]) !!}>
{{ $slot }}
<x-inputs.select-options :options="$options" :selectedValue="$selectedValue" />
</select>

View File

@ -5,140 +5,143 @@
<h1 class="font-semibold text-xl text-gray-800 leading-tight">{{ $title }}</h1>
</x-slot>
<form method="POST" action="{{ ($food->exists ? route('foods.update', $food) : route('foods.store')) }}">
@if ($food->exists)@method('put')@endif
@csrf
<div class="flex flex-col space-y-4">
<div class="flex flex-col space-y-4 md:flex-row md:space-x-4 md:space-y-0">
<!-- Name -->
<div class="flex-auto">
<x-inputs.label for="name" value="Name"/>
@if ($food->exists)@method('put')@endif
@csrf
<div class="flex flex-col space-y-4">
<div class="flex flex-col space-y-4 md:flex-row md:space-x-4 md:space-y-0">
<!-- Name -->
<div class="flex-auto">
<x-inputs.label for="name" value="Name"/>
<x-inputs.input name="name"
type="text"
class="block mt-1 w-full"
autocapitalize="none"
:value="old('name', $food->name)"
required/>
</div>
<x-inputs.input name="name"
type="text"
class="block mt-1 w-full"
autocapitalize="none"
:value="old('name', $food->name)"
:hasError="$errors->has('name')"/>
</div>
<!-- Detail -->
<div class="flex-auto">
<x-inputs.label for="detail" value="Detail"/>
<!-- Detail -->
<div class="flex-auto">
<x-inputs.label for="detail" value="Detail"/>
<x-inputs.input name="detail"
type="text"
class="block mt-1 w-full"
autocapitalize="none"
:value="old('detail', $food->detail)"/>
</div>
<x-inputs.input name="detail"
type="text"
class="block mt-1 w-full"
autocapitalize="none"
:value="old('detail', $food->detail)"/>
</div>
<!-- Brand -->
<div class="flex-auto">
<x-inputs.label for="brand" value="Brand"/>
<!-- Brand -->
<div class="flex-auto">
<x-inputs.label for="brand" value="Brand"/>
<x-inputs.input name="brand"
type="text"
class="block mt-1 w-full"
:value="old('brand', $food->brand)"/>
</div>
</div>
<x-inputs.input name="brand"
type="text"
class="block mt-1 w-full"
:value="old('brand', $food->brand)"/>
</div>
</div>
<div class="flex flex-col space-y-4 sm:flex-row sm:space-x-4 sm:space-y-0">
<!-- Serving size -->
<div>
<x-inputs.label for="serving_size" value="Serving size"/>
<div class="flex flex-col space-y-4 sm:flex-row sm:space-x-4 sm:space-y-0">
<!-- Serving size -->
<div>
<x-inputs.label for="serving_size" value="Serving size"/>
<x-inputs.input name="serving_size"
type="text"
class="block mt-1 w-full"
size="10"
:value="old('serving_size', $food->serving_size_formatted)"
required/>
</div>
<x-inputs.input name="serving_size"
type="text"
class="block mt-1 w-full"
size="10"
:value="old('serving_size', $food->serving_size_formatted)"
:hasError="$errors->has('serving_size')"
required/>
</div>
<!-- Serving unit -->
<div>
<x-inputs.label for="serving_unit" value="Serving unit"/>
<!-- Serving unit -->
<div>
<x-inputs.label for="serving_unit" value="Serving unit"/>
<x-inputs.select name="serving_unit"
class="block mt-1 w-full"
:options="$serving_units"
:selectedValue="old('serving_unit', $food->serving_unit)">
<option value=""></option>
</x-inputs.select>
</div>
<x-inputs.select name="serving_unit"
class="block mt-1 w-full"
:options="$serving_units"
:selectedValue="old('serving_unit', $food->serving_unit)">
<option value=""></option>
</x-inputs.select>
</div>
<!-- Serving unit name -->
<div>
<x-inputs.label for="serving_unit_name" value="Serving unit name"/>
<!-- Serving unit name -->
<div>
<x-inputs.label for="serving_unit_name" value="Serving unit name"/>
<x-inputs.input name="serving_unit_name"
type="text"
autocapitalize="none"
class="block mt-1 w-full"
placeholder="e.g. clove, egg"
size="10"
:value="old('serving_unit_name', $food->serving_unit_name)"/>
</div>
<x-inputs.input name="serving_unit_name"
type="text"
autocapitalize="none"
class="block mt-1 w-full"
placeholder="e.g. clove, egg"
size="10"
:value="old('serving_unit_name', $food->serving_unit_name)"/>
</div>
<!-- Serving weight -->
<div>
<x-inputs.label for="serving_weight" value="Serving weight (g)"/>
<!-- Serving weight -->
<div>
<x-inputs.label for="serving_weight" value="Serving weight (g)"/>
<x-inputs.input name="serving_weight"
type="number"
step="any"
class="block mt-1 w-full"
size="10"
:value="old('serving_weight', $food->serving_weight)"
required/>
</div>
</div>
<x-inputs.input name="serving_weight"
type="number"
step="any"
class="block mt-1 w-full"
size="10"
:value="old('serving_weight', $food->serving_weight)"
:hasError="$errors->has('serving_weight')"
required/>
</div>
</div>
<div class="flex flex-col space-y-4 md:flex-row md:space-y-0">
@foreach (\App\Support\Nutrients::all()->sortBy('weight') as $nutrient)
<!-- {{ ucfirst($nutrient['value']) }} -->
<div class="flex-auto">
<x-inputs.label for="{{ $nutrient['value'] }}"
:value="ucfirst($nutrient['value']) . ($nutrient['unit'] ? ' (' . $nutrient['unit'] . ')' : '')"/>
<div class="flex flex-col space-y-4 md:flex-row md:space-y-0">
@foreach (\App\Support\Nutrients::all()->sortBy('weight') as $nutrient)
<!-- {{ ucfirst($nutrient['value']) }} -->
<div class="flex-auto">
<x-inputs.label for="{{ $nutrient['value'] }}"
:value="ucfirst($nutrient['value']) . ($nutrient['unit'] ? ' (' . $nutrient['unit'] . ')' : '')"/>
<x-inputs.input name="{{ $nutrient['value'] }}"
type="number"
step="any"
class="block w-full mt-1 md:w-5/6"
:value="old($nutrient['value'], $food->{$nutrient['value']})"/>
</div>
@endforeach
</div>
<!-- Tags -->
<x-tagger :defaultTags="$food_tags"/>
<!-- Source -->
<div class="flex-auto">
<x-inputs.label for="source" value="Source" />
<x-inputs.input name="source"
type="text"
class="block mt-1 w-full"
inputmode="url"
:value="old('source', $food->source)" />
</div>
<!-- Notes -->
<div>
<x-inputs.label for="description" value="Description" />
<x-inputs.textarea name="notes"
class="block mt-1 w-full"
:value="old('notes', $food->notes)" />
</div>
<x-inputs.input name="{{ $nutrient['value'] }}"
type="number"
step="any"
class="block w-full mt-1 md:w-5/6"
:value="old($nutrient['value'], $food->{$nutrient['value']})"
:hasError="$errors->has($nutrient['value'])"/>
</div>
@endforeach
</div>
<div class="flex items-center justify-end mt-4">
<x-inputs.button class="ml-3">
{{ ($food->exists ? 'Save' : 'Add') }}
</x-inputs.button>
</div>
</form>
<!-- Tags -->
<x-tagger :defaultTags="$food_tags"/>
<!-- Source -->
<div class="flex-auto">
<x-inputs.label for="source" value="Source" />
<x-inputs.input name="source"
type="text"
class="block mt-1 w-full"
inputmode="url"
:value="old('source', $food->source)" />
</div>
<!-- Notes -->
<div>
<x-inputs.label for="description" value="Description" />
<x-inputs.textarea name="notes"
class="block mt-1 w-full"
:value="old('notes', $food->notes)" />
</div>
</div>
<div class="flex items-center justify-end mt-4">
<x-inputs.button class="ml-3">
{{ ($food->exists ? 'Save' : 'Add') }}
</x-inputs.button>
</div>
</form>
</x-app-layout>

View File

@ -7,7 +7,7 @@
</div>
</h1>
</x-slot>
<div class="flex flex-col justify-between pb-4 md:flex-row md:space-x-4">
<div class="flex flex-col-reverse justify-between pb-4 md:flex-row md:space-x-4">
<div class="flex-1">
<section class="flex flex-col space-y-2">
@if($food->brand)
@ -56,7 +56,7 @@
</section>
</div>
<aside class="flex flex-col space-y-4 mt-8 sm:mt-0 sm:max-w-xs">
<section class="p-1 mb-2 border-2 border-black font-sans md:w-72">
<section class="p-1 border-2 border-black font-sans md:w-72">
<h1 class="text-3xl font-extrabold leading-none">Nutrition Facts</h1>
<section class="flex justify-between font-bold border-b-8 border-black">
<h1>Serving size</h1>
@ -99,8 +99,7 @@
</section>
</div>
</section>
<hr />
<section class="flex flex-col space-y-2">
<section class="flex flex-row space-x-2 justify-around md:flex-col md:space-y-2 md:space-x-0">
<x-button-link.base href="{{ route('foods.edit', $food) }}">
Edit Food
</x-button-link.base>

View File

@ -15,7 +15,8 @@
<x-inputs.input name="from"
type="date"
class="block w-full"
:value="old('from', $goal->from?->toDateString())" />
:value="old('from', $goal->from?->toDateString())"
:hasError="$errors->has('from')" />
</div>
<!-- To -->
@ -24,7 +25,8 @@
<x-inputs.input name="to"
type="date"
class="block w-full"
:value="old('to', $goal->to?->toDateString())" />
:value="old('to', $goal->to?->toDateString())"
:hasError="$errors->has('to')" />
</div>
<!-- Frequency -->
@ -33,7 +35,8 @@
<x-inputs.select name="frequency"
class="block w-full"
:options="$frequencyOptions"
:selectedValue="old('frequency', $goal->frequency)">
:selectedValue="old('frequency', $goal->frequency)"
:hasError="$errors->has('frequency')">
</x-inputs.select>
</div>
@ -44,6 +47,7 @@
class="block w-full"
:options="$nameOptions"
:selectedValue="old('name', $goal->name)"
:hasError="$errors->has('name')"
required>
</x-inputs.select>
</div>
@ -56,6 +60,7 @@
step="any"
class="block w-full"
:value="old('goal', $goal->goal)"
:hasError="$errors->has('goal')"
required />
</div>
</div>

View File

@ -19,6 +19,7 @@
type="date"
class="block w-full"
:value="old('date', $default_date->toDateString())"
:hasError="$errors->has('date')"
required />
</div>
@ -30,6 +31,7 @@
class="block w-full"
:options="$meals"
:selectedValue="old('meal')"
:hasError="$errors->has('meal')"
required>
<option value=""></option>
</x-inputs.select>
@ -43,6 +45,7 @@
type="text"
class="block w-full"
:value="old('summary')"
:hasError="$errors->has('summary')"
required />
</div>
</div>
@ -58,7 +61,8 @@
type="number"
step="any"
class="block w-full"
:value="old($nutrient['value'])"/>
:value="old($nutrient['value'])"
:hasError="$errors->has($nutrient['value'])"/>
</div>
@endforeach
</div>

View File

@ -1,3 +1,4 @@
@php($key = $key ?? null)
<div x-data class="entry-item flex items-center space-x-2">
<div class="flex flex-col space-y-4 w-full">
<!-- Ingredient -->
@ -15,6 +16,7 @@
type="date"
class="block w-full"
:value="$date ?? $default_date->toDateString()"
:hasError="$errors->has('ingredients.date.' . $key)"
required />
</div>
@ -25,8 +27,9 @@
class="block w-full"
:options="$meals"
:selectedValue="$meal ?? null"
:hasError="$errors->has('ingredients.meal.' . $key)"
required>
<option value="">-- Meal --</option>
@if(is_null($key))<option value="">-- Meal --</option>@endif
</x-inputs.select>
</div>
@ -39,6 +42,7 @@
class="block w-full"
placeholder="Amount"
:value="$amount ?? null"
:hasError="$errors->has('ingredients.amount.' . $key)"
required />
</div>
@ -48,8 +52,9 @@
<x-inputs.select name="ingredients[unit][]"
class="block w-full"
:options="$units ?? []"
:selectedValue="$unit ?? null">
<option value="">-- Unit --</option>
:selectedValue="$unit ?? null"
:hasError="$errors->has('ingredients.unit.' . $key)">
@if(is_null($key))<option value="">-- Unit --</option>@endif
</x-inputs.select>
</div>
</div>

View File

@ -33,7 +33,7 @@
<!-- Weight -->
<div class="flex-auto">
<x-inputs.label for="weight" value="Total weight (g)" />
<x-inputs.label for="weight" value="Weight (g)" />
<x-inputs.input name="weight"
type="number"
@ -42,9 +42,19 @@
:value="old('weight', $recipe->weight)" />
</div>
<!-- Volume -->
<div class="flex-auto">
<x-inputs.label for="volume" value="Volume (cups)" />
<x-inputs.input name="volume"
type="text"
class="block mt-1 w-full"
:value="old('volume', $recipe->volume_formatted)" />
</div>
<!-- Prep Time -->
<div class="flex-auto">
<x-inputs.label for="time_prep" value="Prep time (minutes)" />
<x-inputs.label for="time_prep" value="Prep time (min.)" />
<x-inputs.input name="time_prep"
type="number"
@ -56,7 +66,7 @@
<!-- Cooke Time -->
<div class="flex-auto">
<x-inputs.label for="time_cook" value="Cook time (minutes)" />
<x-inputs.label for="time_cook" value="Cook time (min.)" />
<x-inputs.input name="time_cook"
type="number"

View File

@ -16,7 +16,10 @@
<a x-bind:href="recipe.showUrl"
class="hover:text-gray-600" x-text="recipe.name"></a>
</h1>
<h2 class="leading-snug" x-text="`${recipe.servings} servings`"></h2>
<h2 class="leading-snug">
<span x-text="`${recipe.servings} servings`"></span>
<span x-show="recipe.volume" x-text="` / ${recipe.volumeFormatted} cups`"></span>
</h2>
<section class="flex justify-between items-end font-extrabold" x-show="recipe.serving_weight">
<h1>Serving weight</h1>
<div x-text="`${recipe.serving_weight}g`"></div>

View File

@ -1,5 +1,13 @@
@php($key = $key ?? null)
@error("ingredients.amount.{$key}")
@php($amount_error = 'border-red-600')
@enderror
@error("ingredients.unit.{$key}")
@php($unit_error = 'border-red-600')
@enderror
<div class="ingredient draggable">
<x-inputs.input type="hidden" name="ingredients[key][]" :value="$key ?? null" />
<x-inputs.input type="hidden" name="ingredients[key][]" :value="$key" />
<x-inputs.input type="hidden" name="ingredients[weight][]" :value="$weight ?? null" />
<div class="flex items-center space-x-2">
<div class="flex flex-col space-y-4 md:flex-row md:space-x-4 md:space-y-0 w-full">
@ -11,16 +19,17 @@
<div class="w-full">
<x-ingredient-picker :default-id="$ingredient_id ?? null"
:default-type="$ingredient_type ?? null"
:default-name="$ingredient_name ?? null" />
:default-name="$ingredient_name ?? null"
:has-error="(isset($amount) || isset($unit)) && empty($ingredient_id)"/>
</div>
<x-inputs.input name="ingredients[amount][]"
type="text"
size="5"
placeholder="Amount"
class="block"
class="block {{ $amount_error ?? null }}"
:value="$amount ?? null" />
<x-inputs.select name="ingredients[unit][]"
class="block"
class="block {{ $unit_error ?? null }}"
:options="$units_supported ?? []"
:selectedValue="$unit ?? null">
<option value="" selected>Unit</option>

View File

@ -8,7 +8,7 @@
{{ $recipe->name }}
</h1>
</x-slot>
<div class="flex flex-col justify-between pb-4 md:flex-row md:space-x-4">
<div class="flex flex-col-reverse justify-between md:flex-row md:space-x-4">
<div class="flex-1" x-data="{showNutrientsSummary: false}">
@if($recipe->time_total > 0)
<section class="flex justify-between mb-2 p-2 bg-gray-100 rounded max-w-3xl">
@ -81,7 +81,7 @@
</ol>
</div>
</section>
<footer>
<footer class="space-y-2">
@if(!$recipe->tags->isEmpty())
<section>
<h1 class="mb-2 font-bold text-2xl">Tags</h1>
@ -92,12 +92,28 @@
</div>
</section>
@endif
@if($recipe->source)
<section>
<h1 class="mb-2 font-bold text-2xl">Source</h1>
@if(filter_var($recipe->source, FILTER_VALIDATE_URL))
<a class="text-gray-500 hover:text-gray-700 hover:border-gray-300"
href="{{ $recipe->source }}">{{ $recipe->source }}</a>
@else
{{ $recipe->source }}
@endif
</section>
@endif
</footer>
</div>
<aside class="flex flex-col space-y-4 mt-8 md:mt-0 sm:max-w-xs">
<aside class="flex flex-col space-y-4 mb-8 md:mt-0 sm:max-w-xs">
<div class="p-1 border-2 border-black font-sans md:w-72">
<div class="text-3xl font-extrabold leading-none">Nutrition Facts</div>
<div class="leading-snug">{{ $recipe->servings }} {{ \Illuminate\Support\Str::plural('serving', $recipe->servings ) }}</div>
<div class="leading-snug">
{{ $recipe->servings }} {{ \Illuminate\Support\Str::plural('serving', $recipe->servings ) }}
@if($recipe->volume)
/ {{ $recipe->volume_formatted }} {{ \Illuminate\Support\Str::plural('cup', $recipe->volume ) }}
@endif
</div>
@if($recipe->serving_weight)
<div class="flex justify-between items-end font-extrabold">
<div>Serving weight</div>
@ -137,19 +153,7 @@
</div>
</div>
</div>
@if($recipe->source)
<section>
<h1 class="mb-2 font-bold text-2xl">Source</h1>
@if(filter_var($recipe->source, FILTER_VALIDATE_URL))
<a class="text-gray-500 hover:text-gray-700 hover:border-gray-300"
href="{{ $recipe->source }}">{{ $recipe->source }}</a>
@else
{{ $recipe->source }}
@endif
</section>
@endif
<hr />
<section class="flex flex-col space-y-2">
<section class="flex flex-row space-x-2 justify-around md:flex-col md:space-y-2 md:space-x-0">
<x-button-link.base href="{{ route('recipes.edit', $recipe) }}">
Edit Recipe
</x-button-link.base>

View File

@ -3,6 +3,8 @@
namespace Tests\Feature\Support;
use App\Models\Food;
use App\Models\IngredientAmount;
use App\Models\Recipe;
use App\Support\Nutrients;
use Tests\TestCase;
@ -35,8 +37,45 @@ class NutrientsTest extends TestCase
float $expectedMultiplier
): void {
$this->assertEquals(
Nutrients::calculateFoodNutrientMultiplier($food, $amount, $fromUnit),
$expectedMultiplier
$expectedMultiplier,
Nutrients::calculateFoodNutrientMultiplier($food, $amount, $fromUnit)
);
}
/**
* Test valid Recipe nutrient amount calculation.
*
* @dataProvider recipesValidRecipeNutrientAmountsProvider
*/
public function testCalculateValidRecipeNutrientAmount(
string $nutrient,
float $amount,
string $fromUnit,
float $expectedAmount
): void {
/** @var \App\Models\Recipe $recipe */
$recipe = Recipe::factory()
->create(['volume' => 2, 'weight' => 400]);
/** @var \App\Models\Food $food */
$food = Food::factory()->create([
'calories' => 20,
'carbohydrates' => 20,
'cholesterol' => 200,
'fat' => 20,
'protein' => 20,
'sodium' => 200,
]);
$ingredient = new IngredientAmount();
$ingredient->fill([
'amount' => 1,
'unit' => 'serving',
'weight' => 0,
])->ingredient()->associate($food);
$recipe->ingredientAmounts()->save($ingredient);
$this->assertEquals(
$expectedAmount,
Nutrients::calculateRecipeNutrientAmount($recipe, $nutrient, $amount, $fromUnit)
);
}
@ -44,6 +83,26 @@ class NutrientsTest extends TestCase
* Data providers.
*/
/**
* Provide example recipe and expected nutrient amounts.
*/
public function recipesValidRecipeNutrientAmountsProvider(): array {
return [
['calories', 1, 'cup', 10],
['calories', 2, 'cup', 20],
['carbohydrates', 8, 'tbsp', 5],
['carbohydrates', 16, 'tbsp', 10],
['cholesterol', 48, 'tsp', 100],
['cholesterol', 96, 'tsp', 200],
['fat', 100, 'gram', 5],
['fat', 200, 'gram', 10],
['protein', 100, 'gram', 5],
['protein', 200, 'gram', 10],
['sodium', 2, 'oz', Nutrients::$gramsPerOunce],
['sodium', 4, 'oz', Nutrients::$gramsPerOunce * 2],
];
}
/**
* Provide example foods and expected nutrient multipliers.
*/

View File

@ -2,9 +2,9 @@
namespace Tests\Unit\Rules;
use App\Rules\StringIsDecimalOrFraction;
use App\Rules\StringIsPositiveDecimalOrFraction;
class StringIsDecimalOrFractionTest extends RulesTestCase
class StringIsPositiveDecimalOrFractionTest extends RulesTestCase
{
/**
@ -13,7 +13,7 @@ class StringIsDecimalOrFractionTest extends RulesTestCase
public function setUp(): void
{
parent::setUp();
$this->validator->setRules([new StringIsDecimalOrFraction()]);
$this->validator->setRules([new StringIsPositiveDecimalOrFraction()]);
}
/**
@ -49,16 +49,16 @@ class StringIsDecimalOrFractionTest extends RulesTestCase
/**
* Provide valid decimals or fractions
*
* @see \Tests\Unit\Rules\StringIsDecimalOrFractionTest::testStringIsDecimalOrFractionRule()
* @see \Tests\Unit\Rules\StringIsPositiveDecimalOrFractionTest::testStringIsDecimalOrFractionRule()
*/
public function invalidDecimalsAndFractions(): array {
return [['0'], [0], ['string']];
return [['-1'], [-1], ['0'], [0], ['string']];
}
/**
* Provide valid decimals or fractions
*
* @see \Tests\Unit\Rules\StringIsDecimalOrFractionTest::testStringIsDecimalOrFractionRule()
* @see \Tests\Unit\Rules\StringIsPositiveDecimalOrFractionTest::testStringIsDecimalOrFractionRule()
*/
public function validDecimalsAndFractions(): array {
return [