Harmonize recipe time calculations and display across web and mobile

This commit is contained in:
Chris 2026-11-01 20:09:16 -05:00
parent f4c2df6a05
commit 86442f62e0
8 changed files with 447 additions and 22 deletions

View file

@ -79,6 +79,12 @@ class Recipe {
};
}
String get calculatedTotalTime => RecipeTimeUtils.calculateTotalTime(
prepTime: prepTime,
cookTime: cookTime,
totalTime: totalTime,
);
Recipe copyWith({
String? id,
String? title,
@ -115,3 +121,90 @@ class Recipe {
);
}
}
class RecipeTimeUtils {
static int parseDurationToMinutes(String? str) {
if (str == null) return 0;
final s = str.trim();
if (s.isEmpty) return 0;
// ISO 8601 Durations (e.g. PT1H30M, PT600S, PT45M, P0DT1H)
if (s.startsWith('P') || s.startsWith('p')) {
final isoRegex = RegExp(r'P(?:(\d+)D)?T?(?:(\d+(?:\.\d+)?)H)?(?:(\d+)M)?(?:(\d+)S)?', caseSensitive: false);
final match = isoRegex.firstMatch(s);
if (match != null) {
final days = int.tryParse(match.group(1) ?? '0') ?? 0;
final hours = double.tryParse(match.group(2) ?? '0') ?? 0.0;
final mins = int.tryParse(match.group(3) ?? '0') ?? 0;
final secs = int.tryParse(match.group(4) ?? '0') ?? 0;
final totalSecs = (days * 86400) + (hours * 3600).round() + (mins * 60) + secs;
if (totalSecs > 0) return (totalSecs / 60).round();
}
}
int totalMinutes = 0;
bool matched = false;
// Hours: e.g. 1 hr, 1.5 hours, 2 hrs
final hrRegex = RegExp(r'(\d+(?:\.\d+)?)\s*(?:hours?|hrs?|h\b)', caseSensitive: false);
final hrMatch = hrRegex.firstMatch(s);
if (hrMatch != null) {
final h = double.tryParse(hrMatch.group(1) ?? '0') ?? 0.0;
totalMinutes += (h * 60).round();
matched = true;
}
// Minutes: e.g. 15 mins, 10-14 mins, 45m
final minRegex = RegExp(r'(?:(\d+)\s*[-–to]+\s*)?(\d+)\s*(?:minutes?|mins?|m\b)', caseSensitive: false);
final minMatch = minRegex.firstMatch(s);
if (minMatch != null) {
final m = int.tryParse(minMatch.group(2) ?? '0') ?? 0;
totalMinutes += m;
matched = true;
}
if (!matched && RegExp(r'^\d+$').hasMatch(s)) {
return int.tryParse(s) ?? 0;
}
return totalMinutes;
}
static String formatMinutes(int minutes) {
if (minutes <= 0) return '';
final hrs = minutes ~/ 60;
final mins = minutes % 60;
if (hrs > 0 && mins > 0) {
return '$hrs hr${hrs > 1 ? 's' : ''} $mins min${mins > 1 ? 's' : ''}';
}
if (hrs > 0) {
return '$hrs hr${hrs > 1 ? 's' : ''}';
}
return '$mins min${mins > 1 ? 's' : ''}';
}
static String calculateTotalTime({String? prepTime, String? cookTime, String? totalTime}) {
if (totalTime != null && totalTime.trim().isNotEmpty) {
final t = totalTime.trim();
if (t.startsWith('P') || t.startsWith('p')) {
final m = parseDurationToMinutes(t);
if (m > 0) return formatMinutes(m);
}
return t;
}
final prepM = parseDurationToMinutes(prepTime);
final cookM = parseDurationToMinutes(cookTime);
if (prepM > 0 || cookM > 0) {
return formatMinutes(prepM + cookM);
}
if (cookTime != null && cookTime.trim().isNotEmpty) {
final cM = parseDurationToMinutes(cookTime);
return cM > 0 ? formatMinutes(cM) : cookTime.trim();
}
if (prepTime != null && prepTime.trim().isNotEmpty) {
final pM = parseDurationToMinutes(prepTime);
return pM > 0 ? formatMinutes(pM) : prepTime.trim();
}
return '';
}
}

View file

@ -203,10 +203,10 @@ class _RecipesScreenState extends ConsumerState<RecipesScreen> {
const SizedBox(height: 8),
Row(
children: [
if (recipe.cookTime != null && recipe.cookTime!.isNotEmpty) ...[
if (recipe.calculatedTotalTime.isNotEmpty) ...[
const Icon(Icons.schedule_rounded, size: 14, color: AppTheme.primary),
const SizedBox(width: 4),
Text(recipe.cookTime!, style: const TextStyle(fontSize: 12, color: AppTheme.textMuted)),
Text(recipe.calculatedTotalTime, style: const TextStyle(fontSize: 12, color: AppTheme.textMuted)),
const SizedBox(width: 12),
],
if (recipe.servings != null && recipe.servings!.isNotEmpty) ...[
@ -262,6 +262,21 @@ class _RecipesScreenState extends ConsumerState<RecipesScreen> {
),
const SizedBox(height: 16),
Text(recipe.title, style: const TextStyle(fontSize: 22, fontWeight: FontWeight.w800)),
const SizedBox(height: 12),
Wrap(
spacing: 8,
runSpacing: 8,
children: [
if (recipe.servings != null && recipe.servings!.isNotEmpty)
_buildMetaChip(Icons.people_outline_rounded, '${recipe.servings} Servings', AppTheme.secondary),
if (recipe.prepTime != null && recipe.prepTime!.isNotEmpty)
_buildMetaChip(Icons.schedule_rounded, 'Prep: ${recipe.prepTime}', AppTheme.primary),
if (recipe.cookTime != null && recipe.cookTime!.isNotEmpty)
_buildMetaChip(Icons.local_fire_department_rounded, 'Cook: ${recipe.cookTime}', Colors.amber),
if (recipe.calculatedTotalTime.isNotEmpty)
_buildMetaChip(Icons.timer_rounded, 'Total: ${recipe.calculatedTotalTime}', Colors.tealAccent),
],
),
const SizedBox(height: 16),
SizedBox(
width: double.infinity,
@ -307,4 +322,26 @@ class _RecipesScreenState extends ConsumerState<RecipesScreen> {
),
);
}
Widget _buildMetaChip(IconData icon, String label, Color color) {
return Container(
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 5),
decoration: BoxDecoration(
color: color.withOpacity(0.12),
borderRadius: BorderRadius.circular(8),
border: Border.all(color: color.withOpacity(0.3)),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(icon, size: 14, color: color),
const SizedBox(width: 5),
Text(
label,
style: TextStyle(fontSize: 12, fontWeight: FontWeight.w600, color: color),
),
],
),
);
}
}

View file

@ -95,11 +95,20 @@ class _QuickShareSheetState extends ConsumerState<QuickShareSheet> {
Future<void> _handleSave() async {
if (_scrapedRecipe == null) return;
final prepTime = _prepTimeController.text.trim();
final cookTime = _cookTimeController.text.trim();
final totalTime = RecipeTimeUtils.calculateTotalTime(
prepTime: prepTime,
cookTime: cookTime,
totalTime: _scrapedRecipe!.totalTime,
);
final updated = _scrapedRecipe!.copyWith(
title: _titleController.text.trim().isEmpty ? 'Untitled Recipe' : _titleController.text.trim(),
servings: _servingsController.text.trim(),
prepTime: _prepTimeController.text.trim(),
cookTime: _cookTimeController.text.trim(),
prepTime: prepTime,
cookTime: cookTime,
totalTime: totalTime,
sourceUrl: widget.url ?? _scrapedRecipe!.sourceUrl,
);

View file

@ -192,6 +192,7 @@ const elements = {
tagServings: document.getElementById('tag-servings'),
tagPrep: document.getElementById('tag-prep'),
tagCook: document.getElementById('tag-cook'),
tagTotal: document.getElementById('tag-total'),
tagSource: document.getElementById('tag-source'),
recipeDetailDescription: document.getElementById('recipe-detail-description'),
recipeDetailIngredients: document.getElementById('recipe-detail-ingredients'),
@ -1324,6 +1325,8 @@ function renderLibrary() {
`;
}
const displayTotalTime = calculateTotalTime(recipe.prepTime, recipe.cookTime, recipe.totalTime);
card.innerHTML = `
<div class="recipe-card-img-wrapper">
${imgBlock}
@ -1336,7 +1339,7 @@ function renderLibrary() {
<div class="recipe-card-meta">
${recipe.servings ? `<span><i data-lucide="users"></i> ${recipe.servings} Servings</span>` : ''}
${recipe.totalTime ? `<span><i data-lucide="clock"></i> ${recipe.totalTime}</span>` : ''}
${displayTotalTime ? `<span><i data-lucide="clock"></i> ${displayTotalTime}</span>` : ''}
</div>
</div>
`;
@ -1351,6 +1354,94 @@ function filterRecipes() {
renderLibrary();
}
// -------------------------------------------------------------
// DURATION & TOTAL TIME CALCULATION UTILITIES
// -------------------------------------------------------------
function parseDurationToMinutes(str) {
if (!str || typeof str !== 'string') return 0;
const s = str.trim();
if (!s) return 0;
// 1. ISO 8601 Durations (e.g. PT1H30M, PT600S, PT45M, P0DT1H, etc.)
if (s.startsWith('P') || s.startsWith('p')) {
const isoMatch = s.match(/P(?:(\d+)D)?T?(?:(\d+(?:\.\d+)?)H)?(?:(\d+)M)?(?:(\d+)S)?/i);
if (isoMatch) {
const days = parseInt(isoMatch[1] || '0', 10);
const hours = parseFloat(isoMatch[2] || '0');
const mins = parseInt(isoMatch[3] || '0', 10);
const secs = parseInt(isoMatch[4] || '0', 10);
const totalSecs = (days * 86400) + Math.round(hours * 3600) + (mins * 60) + secs;
if (totalSecs > 0) return Math.round(totalSecs / 60);
}
}
// 2. Natural language strings: '1 hr 30 mins', '45 mins', '1.5 hours', '10-14 mins'
let totalMinutes = 0;
let matched = false;
// Hours: e.g. '1.5 hrs', '2 hours', '1 hr'
const hrMatch = s.match(/(\d+(?:\.\d+)?)\s*(?:hours?|hrs?|h\b)/i);
if (hrMatch) {
totalMinutes += Math.round(parseFloat(hrMatch[1]) * 60);
matched = true;
}
// Minutes: e.g. '30 mins', '10-14 mins', '45m'
const minMatch = s.match(/(?:(\d+)\s*[-–to]+\s*)?(\d+)\s*(?:minutes?|mins?|m\b)/i);
if (minMatch) {
const m = parseInt(minMatch[2], 10);
if (!isNaN(m)) {
totalMinutes += m;
matched = true;
}
}
// Fallback: If string is pure digits, assume minutes
if (!matched && /^\d+$/.test(s)) {
return parseInt(s, 10);
}
return totalMinutes;
}
function formatMinutes(minutes) {
if (!minutes || minutes <= 0) return '';
const hrs = Math.floor(minutes / 60);
const mins = minutes % 60;
if (hrs > 0 && mins > 0) {
return `${hrs} hr${hrs > 1 ? 's' : ''} ${mins} min${mins > 1 ? 's' : ''}`;
}
if (hrs > 0) {
return `${hrs} hr${hrs > 1 ? 's' : ''}`;
}
return `${mins} min${mins > 1 ? 's' : ''}`;
}
function calculateTotalTime(prepTime, cookTime, existingTotalTime) {
if (existingTotalTime && existingTotalTime.trim()) {
if (existingTotalTime.startsWith('P') || existingTotalTime.startsWith('p')) {
const mins = parseDurationToMinutes(existingTotalTime);
if (mins > 0) return formatMinutes(mins);
}
return existingTotalTime.trim();
}
const prepM = parseDurationToMinutes(prepTime);
const cookM = parseDurationToMinutes(cookTime);
if (prepM > 0 || cookM > 0) {
return formatMinutes(prepM + cookM);
}
if (cookTime && cookTime.trim()) {
const cookMOnly = parseDurationToMinutes(cookTime);
return cookMOnly > 0 ? formatMinutes(cookMOnly) : cookTime.trim();
}
if (prepTime && prepTime.trim()) {
const prepMOnly = parseDurationToMinutes(prepTime);
return prepMOnly > 0 ? formatMinutes(prepMOnly) : prepTime.trim();
}
return '';
}
// -------------------------------------------------------------
// DYNAMIC SERVINGS QUANTITY SCALER
// -------------------------------------------------------------
@ -1518,6 +1609,14 @@ function showRecipeDetails(recipe) {
elements.tagCook.style.display = 'none';
}
const calculatedTotal = calculateTotalTime(recipe.prepTime, recipe.cookTime, recipe.totalTime);
if (calculatedTotal) {
elements.tagTotal.style.display = 'flex';
elements.tagTotal.querySelector('.val').textContent = calculatedTotal;
} else {
elements.tagTotal.style.display = 'none';
}
if (recipe.sourceUrl) {
elements.tagSource.style.display = 'flex';
elements.tagSource.href = recipe.sourceUrl;

View file

@ -803,6 +803,7 @@
<span id="tag-servings"><i data-lucide="users"></i> <span class="val">4 servings</span></span>
<span id="tag-prep"><i data-lucide="clock"></i> Prep: <span class="val">15 mins</span></span>
<span id="tag-cook"><i data-lucide="flame"></i> Cook: <span class="val">25 mins</span></span>
<span id="tag-total"><i data-lucide="timer"></i> Total: <span class="val">40 mins</span></span>
<a id="tag-source" href="#" target="_blank" rel="noopener noreferrer" style="display: none;">
<i data-lucide="external-link"></i> <span class="val">Original Source</span>
</a>

View file

@ -135,6 +135,7 @@
<span id="tag-servings"><i data-lucide="users"></i> <span class="val">4 servings</span></span>
<span id="tag-prep"><i data-lucide="clock"></i> Prep: <span class="val">15 mins</span></span>
<span id="tag-cook"><i data-lucide="flame"></i> Cook: <span class="val">25 mins</span></span>
<span id="tag-total"><i data-lucide="timer"></i> Total: <span class="val">40 mins</span></span>
<a id="tag-source" href="#" target="_blank" rel="noopener noreferrer">
<i data-lucide="external-link"></i> <span class="val">Original Source</span>
</a>

View file

@ -18,6 +18,7 @@ const elements = {
tagServings: document.getElementById('tag-servings'),
tagPrep: document.getElementById('tag-prep'),
tagCook: document.getElementById('tag-cook'),
tagTotal: document.getElementById('tag-total'),
tagSource: document.getElementById('tag-source'),
recipeDetailDescription: document.getElementById('recipe-detail-description'),
recipeDetailIngredients: document.getElementById('recipe-detail-ingredients'),
@ -161,6 +162,14 @@ function renderRecipePage(recipe) {
elements.tagCook.style.display = 'none';
}
const calculatedTotal = calculateTotalTime(recipe.prepTime, recipe.cookTime, recipe.totalTime);
if (calculatedTotal) {
elements.tagTotal.style.display = 'flex';
elements.tagTotal.querySelector('.val').textContent = calculatedTotal;
} else {
elements.tagTotal.style.display = 'none';
}
if (recipe.sourceUrl) {
elements.tagSource.style.display = 'flex';
elements.tagSource.href = recipe.sourceUrl;
@ -175,6 +184,86 @@ function renderRecipePage(recipe) {
lucide.createIcons();
}
// DURATION & TOTAL TIME CALCULATION UTILITIES
function parseDurationToMinutes(str) {
if (!str || typeof str !== 'string') return 0;
const s = str.trim();
if (!s) return 0;
if (s.startsWith('P') || s.startsWith('p')) {
const isoMatch = s.match(/P(?:(\d+)D)?T?(?:(\d+(?:\.\d+)?)H)?(?:(\d+)M)?(?:(\d+)S)?/i);
if (isoMatch) {
const days = parseInt(isoMatch[1] || '0', 10);
const hours = parseFloat(isoMatch[2] || '0');
const mins = parseInt(isoMatch[3] || '0', 10);
const secs = parseInt(isoMatch[4] || '0', 10);
const totalSecs = (days * 86400) + Math.round(hours * 3600) + (mins * 60) + secs;
if (totalSecs > 0) return Math.round(totalSecs / 60);
}
}
let totalMinutes = 0;
let matched = false;
const hrMatch = s.match(/(\d+(?:\.\d+)?)\s*(?:hours?|hrs?|h\b)/i);
if (hrMatch) {
totalMinutes += Math.round(parseFloat(hrMatch[1]) * 60);
matched = true;
}
const minMatch = s.match(/(?:(\d+)\s*[-–to]+\s*)?(\d+)\s*(?:minutes?|mins?|m\b)/i);
if (minMatch) {
const m = parseInt(minMatch[2], 10);
if (!isNaN(m)) {
totalMinutes += m;
matched = true;
}
}
if (!matched && /^\d+$/.test(s)) {
return parseInt(s, 10);
}
return totalMinutes;
}
function formatMinutes(minutes) {
if (!minutes || minutes <= 0) return '';
const hrs = Math.floor(minutes / 60);
const mins = minutes % 60;
if (hrs > 0 && mins > 0) {
return `${hrs} hr${hrs > 1 ? 's' : ''} ${mins} min${mins > 1 ? 's' : ''}`;
}
if (hrs > 0) {
return `${hrs} hr${hrs > 1 ? 's' : ''}`;
}
return `${mins} min${mins > 1 ? 's' : ''}`;
}
function calculateTotalTime(prepTime, cookTime, existingTotalTime) {
if (existingTotalTime && existingTotalTime.trim()) {
if (existingTotalTime.startsWith('P') || existingTotalTime.startsWith('p')) {
const mins = parseDurationToMinutes(existingTotalTime);
if (mins > 0) return formatMinutes(mins);
}
return existingTotalTime.trim();
}
const prepM = parseDurationToMinutes(prepTime);
const cookM = parseDurationToMinutes(cookTime);
if (prepM > 0 || cookM > 0) {
return formatMinutes(prepM + cookM);
}
if (cookTime && cookTime.trim()) {
const cookMOnly = parseDurationToMinutes(cookTime);
return cookMOnly > 0 ? formatMinutes(cookMOnly) : cookTime.trim();
}
if (prepTime && prepTime.trim()) {
const prepMOnly = parseDurationToMinutes(prepTime);
return prepMOnly > 0 ? formatMinutes(prepMOnly) : prepTime.trim();
}
return '';
}
// Ingredient Scaling Algorithms
function adjustMultiplier(change) {
const newMulti = currentMultiplier + change;

130
server.js
View file

@ -400,18 +400,98 @@ const publicShareLimiter = rateLimit({
message: { error: 'Too many queries from this IP. Please try again in 15 minutes.' }
});
// Helper: Parse ISO 8601 Durations (e.g. PT1H30M to "1 hr 30 mins")
// Helper: Parse natural or ISO duration strings into total integer minutes
function parseDurationToMinutes(str) {
if (!str || typeof str !== 'string') return 0;
const s = str.trim();
if (!s) return 0;
// 1. ISO 8601 Durations (e.g. PT1H30M, PT600S, PT45M, P0DT1H, etc.)
if (s.startsWith('P') || s.startsWith('p')) {
const isoMatch = s.match(/P(?:(\d+)D)?T?(?:(\d+(?:\.\d+)?)H)?(?:(\d+)M)?(?:(\d+)S)?/i);
if (isoMatch) {
const days = parseInt(isoMatch[1] || '0', 10);
const hours = parseFloat(isoMatch[2] || '0');
const mins = parseInt(isoMatch[3] || '0', 10);
const secs = parseInt(isoMatch[4] || '0', 10);
const totalSecs = (days * 86400) + Math.round(hours * 3600) + (mins * 60) + secs;
if (totalSecs > 0) return Math.round(totalSecs / 60);
}
}
// 2. Natural language strings: '1 hr 30 mins', '45 mins', '1.5 hours', '10-14 mins'
let totalMinutes = 0;
let matched = false;
// Hours: e.g. '1.5 hrs', '2 hours', '1 hr'
const hrMatch = s.match(/(\d+(?:\.\d+)?)\s*(?:hours?|hrs?|h\b)/i);
if (hrMatch) {
totalMinutes += Math.round(parseFloat(hrMatch[1]) * 60);
matched = true;
}
// Minutes: e.g. '30 mins', '10-14 mins', '45m'
const minMatch = s.match(/(?:(\d+)\s*[-–to]+\s*)?(\d+)\s*(?:minutes?|mins?|m\b)/i);
if (minMatch) {
const m = parseInt(minMatch[2], 10);
if (!isNaN(m)) {
totalMinutes += m;
matched = true;
}
}
// Fallback: If string is pure digits, assume minutes
if (!matched && /^\d+$/.test(s)) {
return parseInt(s, 10);
}
return totalMinutes;
}
// Helper: Format minutes into human-readable duration
function formatMinutes(minutes) {
if (!minutes || minutes <= 0) return '';
const hrs = Math.floor(minutes / 60);
const mins = minutes % 60;
if (hrs > 0 && mins > 0) {
return `${hrs} hr${hrs > 1 ? 's' : ''} ${mins} min${mins > 1 ? 's' : ''}`;
}
if (hrs > 0) {
return `${hrs} hr${hrs > 1 ? 's' : ''}`;
}
return `${mins} min${mins > 1 ? 's' : ''}`;
}
// Helper: Calculate Total Time from Prep Time and Cook Time if totalTime is missing
function calculateTotalTime(prepTime, cookTime, existingTotalTime) {
if (existingTotalTime && existingTotalTime.trim()) {
if (existingTotalTime.startsWith('P') || existingTotalTime.startsWith('p')) {
const mins = parseDurationToMinutes(existingTotalTime);
if (mins > 0) return formatMinutes(mins);
}
return existingTotalTime.trim();
}
const prepM = parseDurationToMinutes(prepTime);
const cookM = parseDurationToMinutes(cookTime);
if (prepM > 0 || cookM > 0) {
return formatMinutes(prepM + cookM);
}
if (cookTime && cookTime.trim()) {
const cookMOnly = parseDurationToMinutes(cookTime);
return cookMOnly > 0 ? formatMinutes(cookMOnly) : cookTime.trim();
}
if (prepTime && prepTime.trim()) {
const prepMOnly = parseDurationToMinutes(prepTime);
return prepMOnly > 0 ? formatMinutes(prepMOnly) : prepTime.trim();
}
return '';
}
// Helper: Parse ISO 8601 Durations (e.g. PT1H30M to "1 hr 30 mins", PT600S to "10 mins")
function parseISODuration(duration) {
if (!duration || typeof duration !== 'string') return '';
const match = duration.match(/PT(?:(\d+)H)?(?:(\d+)M)?(?:(\d+)S)?/);
if (!match) return duration;
const hours = match[1] ? parseInt(match[1], 10) : 0;
const minutes = match[2] ? parseInt(match[2], 10) : 0;
let result = '';
if (hours > 0) result += `${hours} hr${hours > 1 ? 's' : ''} `;
if (minutes > 0) result += `${minutes} min${minutes > 1 ? 's' : ''}`;
return result.trim() || duration;
const mins = parseDurationToMinutes(duration);
return mins > 0 ? formatMinutes(mins) : duration;
}
// Helper: Extract recipe instructions from schema format
@ -553,8 +633,15 @@ function formatRecipe(row) {
}
}
const prepTime = row.prepTime ? (row.prepTime.startsWith('P') || row.prepTime.startsWith('p') ? parseISODuration(row.prepTime) : row.prepTime) : '';
const cookTime = row.cookTime ? (row.cookTime.startsWith('P') || row.cookTime.startsWith('p') ? parseISODuration(row.cookTime) : row.cookTime) : '';
const totalTime = calculateTotalTime(prepTime, cookTime, row.totalTime);
return {
...row,
prepTime,
cookTime,
totalTime,
isPublic: row.isPublic === 1,
images: JSON.parse(row.images || '[]'),
ingredients: JSON.parse(row.ingredients || '[]'),
@ -1050,6 +1137,9 @@ app.post('/api/recipes', requireAuth, (req, res) => {
const isPublic = req.body.isPublic !== false ? 1 : 0;
const shareToken = isPublic === 1 ? crypto.randomBytes(16).toString('hex') : null;
const newId = Date.now().toString(36) + Math.random().toString(36).substr(2, 5);
const prepTime = req.body.prepTime ? (req.body.prepTime.startsWith('P') || req.body.prepTime.startsWith('p') ? parseISODuration(req.body.prepTime) : req.body.prepTime) : '';
const cookTime = req.body.cookTime ? (req.body.cookTime.startsWith('P') || req.body.cookTime.startsWith('p') ? parseISODuration(req.body.cookTime) : req.body.cookTime) : '';
const totalTime = calculateTotalTime(prepTime, cookTime, req.body.totalTime);
try {
db.prepare(`
@ -1065,9 +1155,9 @@ app.post('/api/recipes', requireAuth, (req, res) => {
req.body.title || 'Untitled Recipe',
req.body.description || '',
req.body.servings || '',
req.body.prepTime || '',
req.body.cookTime || '',
req.body.totalTime || '',
prepTime,
cookTime,
totalTime,
req.body.image || '',
JSON.stringify(Array.isArray(req.body.images) ? req.body.images : []),
JSON.stringify(Array.isArray(req.body.ingredients) ? req.body.ingredients : []),
@ -1109,6 +1199,12 @@ app.put('/api/recipes/:id', requireAuth, (req, res) => {
shareToken = null; // Revoke shareToken if it is made private
}
const prepTime = req.body.prepTime !== undefined ? req.body.prepTime : existing.prepTime;
const cookTime = req.body.cookTime !== undefined ? req.body.cookTime : existing.cookTime;
const totalTime = req.body.totalTime !== undefined && req.body.totalTime.trim()
? req.body.totalTime.trim()
: calculateTotalTime(prepTime, cookTime, existing.totalTime);
db.prepare(`
UPDATE recipes
SET title = ?,
@ -1132,9 +1228,9 @@ app.put('/api/recipes/:id', requireAuth, (req, res) => {
Array.isArray(req.body.ingredients) ? JSON.stringify(req.body.ingredients) : existing.ingredients,
Array.isArray(req.body.instructions) ? JSON.stringify(req.body.instructions) : existing.instructions,
req.body.servings !== undefined ? req.body.servings : existing.servings,
req.body.prepTime !== undefined ? req.body.prepTime : existing.prepTime,
req.body.cookTime !== undefined ? req.body.cookTime : existing.cookTime,
req.body.totalTime !== undefined ? req.body.totalTime : existing.totalTime,
prepTime,
cookTime,
totalTime,
req.body.image !== undefined ? req.body.image : existing.image,
Array.isArray(req.body.images) ? JSON.stringify(req.body.images) : existing.images,
req.body.sourceUrl !== undefined ? req.body.sourceUrl : existing.sourceUrl,
@ -1256,7 +1352,7 @@ function parseRecipeFromHtml(html, url) {
recipeData.prepTime = parseISODuration(schemaRecipe.prepTime || '');
recipeData.cookTime = parseISODuration(schemaRecipe.cookTime || '');
recipeData.totalTime = parseISODuration(schemaRecipe.totalTime || '');
recipeData.totalTime = calculateTotalTime(recipeData.prepTime, recipeData.cookTime, parseISODuration(schemaRecipe.totalTime || ''));
const rawIngredients = schemaRecipe.recipeIngredient || schemaRecipe.recipeIngredients || [];
if (typeof rawIngredients === 'string') {