mirror of
https://gitlab.com/Zer0interrupt/recipe-manager.git
synced 2026-11-01 23:24:11 -05:00
Harmonize recipe time calculations and display across web and mobile
This commit is contained in:
parent
f4c2df6a05
commit
86442f62e0
8 changed files with 447 additions and 22 deletions
|
|
@ -79,6 +79,12 @@ class Recipe {
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
String get calculatedTotalTime => RecipeTimeUtils.calculateTotalTime(
|
||||||
|
prepTime: prepTime,
|
||||||
|
cookTime: cookTime,
|
||||||
|
totalTime: totalTime,
|
||||||
|
);
|
||||||
|
|
||||||
Recipe copyWith({
|
Recipe copyWith({
|
||||||
String? id,
|
String? id,
|
||||||
String? title,
|
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 '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -203,10 +203,10 @@ class _RecipesScreenState extends ConsumerState<RecipesScreen> {
|
||||||
const SizedBox(height: 8),
|
const SizedBox(height: 8),
|
||||||
Row(
|
Row(
|
||||||
children: [
|
children: [
|
||||||
if (recipe.cookTime != null && recipe.cookTime!.isNotEmpty) ...[
|
if (recipe.calculatedTotalTime.isNotEmpty) ...[
|
||||||
const Icon(Icons.schedule_rounded, size: 14, color: AppTheme.primary),
|
const Icon(Icons.schedule_rounded, size: 14, color: AppTheme.primary),
|
||||||
const SizedBox(width: 4),
|
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),
|
const SizedBox(width: 12),
|
||||||
],
|
],
|
||||||
if (recipe.servings != null && recipe.servings!.isNotEmpty) ...[
|
if (recipe.servings != null && recipe.servings!.isNotEmpty) ...[
|
||||||
|
|
@ -262,6 +262,21 @@ class _RecipesScreenState extends ConsumerState<RecipesScreen> {
|
||||||
),
|
),
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
Text(recipe.title, style: const TextStyle(fontSize: 22, fontWeight: FontWeight.w800)),
|
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),
|
const SizedBox(height: 16),
|
||||||
SizedBox(
|
SizedBox(
|
||||||
width: double.infinity,
|
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),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -95,11 +95,20 @@ class _QuickShareSheetState extends ConsumerState<QuickShareSheet> {
|
||||||
Future<void> _handleSave() async {
|
Future<void> _handleSave() async {
|
||||||
if (_scrapedRecipe == null) return;
|
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(
|
final updated = _scrapedRecipe!.copyWith(
|
||||||
title: _titleController.text.trim().isEmpty ? 'Untitled Recipe' : _titleController.text.trim(),
|
title: _titleController.text.trim().isEmpty ? 'Untitled Recipe' : _titleController.text.trim(),
|
||||||
servings: _servingsController.text.trim(),
|
servings: _servingsController.text.trim(),
|
||||||
prepTime: _prepTimeController.text.trim(),
|
prepTime: prepTime,
|
||||||
cookTime: _cookTimeController.text.trim(),
|
cookTime: cookTime,
|
||||||
|
totalTime: totalTime,
|
||||||
sourceUrl: widget.url ?? _scrapedRecipe!.sourceUrl,
|
sourceUrl: widget.url ?? _scrapedRecipe!.sourceUrl,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
|
||||||
101
public/app.js
101
public/app.js
|
|
@ -192,6 +192,7 @@ const elements = {
|
||||||
tagServings: document.getElementById('tag-servings'),
|
tagServings: document.getElementById('tag-servings'),
|
||||||
tagPrep: document.getElementById('tag-prep'),
|
tagPrep: document.getElementById('tag-prep'),
|
||||||
tagCook: document.getElementById('tag-cook'),
|
tagCook: document.getElementById('tag-cook'),
|
||||||
|
tagTotal: document.getElementById('tag-total'),
|
||||||
tagSource: document.getElementById('tag-source'),
|
tagSource: document.getElementById('tag-source'),
|
||||||
recipeDetailDescription: document.getElementById('recipe-detail-description'),
|
recipeDetailDescription: document.getElementById('recipe-detail-description'),
|
||||||
recipeDetailIngredients: document.getElementById('recipe-detail-ingredients'),
|
recipeDetailIngredients: document.getElementById('recipe-detail-ingredients'),
|
||||||
|
|
@ -1324,6 +1325,8 @@ function renderLibrary() {
|
||||||
`;
|
`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const displayTotalTime = calculateTotalTime(recipe.prepTime, recipe.cookTime, recipe.totalTime);
|
||||||
|
|
||||||
card.innerHTML = `
|
card.innerHTML = `
|
||||||
<div class="recipe-card-img-wrapper">
|
<div class="recipe-card-img-wrapper">
|
||||||
${imgBlock}
|
${imgBlock}
|
||||||
|
|
@ -1336,7 +1339,7 @@ function renderLibrary() {
|
||||||
|
|
||||||
<div class="recipe-card-meta">
|
<div class="recipe-card-meta">
|
||||||
${recipe.servings ? `<span><i data-lucide="users"></i> ${recipe.servings} Servings</span>` : ''}
|
${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>
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
|
|
@ -1351,6 +1354,94 @@ function filterRecipes() {
|
||||||
renderLibrary();
|
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
|
// DYNAMIC SERVINGS QUANTITY SCALER
|
||||||
// -------------------------------------------------------------
|
// -------------------------------------------------------------
|
||||||
|
|
@ -1518,6 +1609,14 @@ function showRecipeDetails(recipe) {
|
||||||
elements.tagCook.style.display = 'none';
|
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) {
|
if (recipe.sourceUrl) {
|
||||||
elements.tagSource.style.display = 'flex';
|
elements.tagSource.style.display = 'flex';
|
||||||
elements.tagSource.href = recipe.sourceUrl;
|
elements.tagSource.href = recipe.sourceUrl;
|
||||||
|
|
|
||||||
|
|
@ -803,6 +803,7 @@
|
||||||
<span id="tag-servings"><i data-lucide="users"></i> <span class="val">4 servings</span></span>
|
<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-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-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;">
|
<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>
|
<i data-lucide="external-link"></i> <span class="val">Original Source</span>
|
||||||
</a>
|
</a>
|
||||||
|
|
|
||||||
|
|
@ -135,6 +135,7 @@
|
||||||
<span id="tag-servings"><i data-lucide="users"></i> <span class="val">4 servings</span></span>
|
<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-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-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">
|
<a id="tag-source" href="#" target="_blank" rel="noopener noreferrer">
|
||||||
<i data-lucide="external-link"></i> <span class="val">Original Source</span>
|
<i data-lucide="external-link"></i> <span class="val">Original Source</span>
|
||||||
</a>
|
</a>
|
||||||
|
|
|
||||||
|
|
@ -18,6 +18,7 @@ const elements = {
|
||||||
tagServings: document.getElementById('tag-servings'),
|
tagServings: document.getElementById('tag-servings'),
|
||||||
tagPrep: document.getElementById('tag-prep'),
|
tagPrep: document.getElementById('tag-prep'),
|
||||||
tagCook: document.getElementById('tag-cook'),
|
tagCook: document.getElementById('tag-cook'),
|
||||||
|
tagTotal: document.getElementById('tag-total'),
|
||||||
tagSource: document.getElementById('tag-source'),
|
tagSource: document.getElementById('tag-source'),
|
||||||
recipeDetailDescription: document.getElementById('recipe-detail-description'),
|
recipeDetailDescription: document.getElementById('recipe-detail-description'),
|
||||||
recipeDetailIngredients: document.getElementById('recipe-detail-ingredients'),
|
recipeDetailIngredients: document.getElementById('recipe-detail-ingredients'),
|
||||||
|
|
@ -161,6 +162,14 @@ function renderRecipePage(recipe) {
|
||||||
elements.tagCook.style.display = 'none';
|
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) {
|
if (recipe.sourceUrl) {
|
||||||
elements.tagSource.style.display = 'flex';
|
elements.tagSource.style.display = 'flex';
|
||||||
elements.tagSource.href = recipe.sourceUrl;
|
elements.tagSource.href = recipe.sourceUrl;
|
||||||
|
|
@ -175,6 +184,86 @@ function renderRecipePage(recipe) {
|
||||||
lucide.createIcons();
|
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
|
// Ingredient Scaling Algorithms
|
||||||
function adjustMultiplier(change) {
|
function adjustMultiplier(change) {
|
||||||
const newMulti = currentMultiplier + change;
|
const newMulti = currentMultiplier + change;
|
||||||
|
|
|
||||||
130
server.js
130
server.js
|
|
@ -400,18 +400,98 @@ const publicShareLimiter = rateLimit({
|
||||||
message: { error: 'Too many queries from this IP. Please try again in 15 minutes.' }
|
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) {
|
function parseISODuration(duration) {
|
||||||
if (!duration || typeof duration !== 'string') return '';
|
if (!duration || typeof duration !== 'string') return '';
|
||||||
const match = duration.match(/PT(?:(\d+)H)?(?:(\d+)M)?(?:(\d+)S)?/);
|
const mins = parseDurationToMinutes(duration);
|
||||||
if (!match) return duration;
|
return mins > 0 ? formatMinutes(mins) : 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;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Helper: Extract recipe instructions from schema format
|
// 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 {
|
return {
|
||||||
...row,
|
...row,
|
||||||
|
prepTime,
|
||||||
|
cookTime,
|
||||||
|
totalTime,
|
||||||
isPublic: row.isPublic === 1,
|
isPublic: row.isPublic === 1,
|
||||||
images: JSON.parse(row.images || '[]'),
|
images: JSON.parse(row.images || '[]'),
|
||||||
ingredients: JSON.parse(row.ingredients || '[]'),
|
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 isPublic = req.body.isPublic !== false ? 1 : 0;
|
||||||
const shareToken = isPublic === 1 ? crypto.randomBytes(16).toString('hex') : null;
|
const shareToken = isPublic === 1 ? crypto.randomBytes(16).toString('hex') : null;
|
||||||
const newId = Date.now().toString(36) + Math.random().toString(36).substr(2, 5);
|
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 {
|
try {
|
||||||
db.prepare(`
|
db.prepare(`
|
||||||
|
|
@ -1065,9 +1155,9 @@ app.post('/api/recipes', requireAuth, (req, res) => {
|
||||||
req.body.title || 'Untitled Recipe',
|
req.body.title || 'Untitled Recipe',
|
||||||
req.body.description || '',
|
req.body.description || '',
|
||||||
req.body.servings || '',
|
req.body.servings || '',
|
||||||
req.body.prepTime || '',
|
prepTime,
|
||||||
req.body.cookTime || '',
|
cookTime,
|
||||||
req.body.totalTime || '',
|
totalTime,
|
||||||
req.body.image || '',
|
req.body.image || '',
|
||||||
JSON.stringify(Array.isArray(req.body.images) ? req.body.images : []),
|
JSON.stringify(Array.isArray(req.body.images) ? req.body.images : []),
|
||||||
JSON.stringify(Array.isArray(req.body.ingredients) ? req.body.ingredients : []),
|
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
|
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(`
|
db.prepare(`
|
||||||
UPDATE recipes
|
UPDATE recipes
|
||||||
SET title = ?,
|
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.ingredients) ? JSON.stringify(req.body.ingredients) : existing.ingredients,
|
||||||
Array.isArray(req.body.instructions) ? JSON.stringify(req.body.instructions) : existing.instructions,
|
Array.isArray(req.body.instructions) ? JSON.stringify(req.body.instructions) : existing.instructions,
|
||||||
req.body.servings !== undefined ? req.body.servings : existing.servings,
|
req.body.servings !== undefined ? req.body.servings : existing.servings,
|
||||||
req.body.prepTime !== undefined ? req.body.prepTime : existing.prepTime,
|
prepTime,
|
||||||
req.body.cookTime !== undefined ? req.body.cookTime : existing.cookTime,
|
cookTime,
|
||||||
req.body.totalTime !== undefined ? req.body.totalTime : existing.totalTime,
|
totalTime,
|
||||||
req.body.image !== undefined ? req.body.image : existing.image,
|
req.body.image !== undefined ? req.body.image : existing.image,
|
||||||
Array.isArray(req.body.images) ? JSON.stringify(req.body.images) : existing.images,
|
Array.isArray(req.body.images) ? JSON.stringify(req.body.images) : existing.images,
|
||||||
req.body.sourceUrl !== undefined ? req.body.sourceUrl : existing.sourceUrl,
|
req.body.sourceUrl !== undefined ? req.body.sourceUrl : existing.sourceUrl,
|
||||||
|
|
@ -1256,7 +1352,7 @@ function parseRecipeFromHtml(html, url) {
|
||||||
|
|
||||||
recipeData.prepTime = parseISODuration(schemaRecipe.prepTime || '');
|
recipeData.prepTime = parseISODuration(schemaRecipe.prepTime || '');
|
||||||
recipeData.cookTime = parseISODuration(schemaRecipe.cookTime || '');
|
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 || [];
|
const rawIngredients = schemaRecipe.recipeIngredient || schemaRecipe.recipeIngredients || [];
|
||||||
if (typeof rawIngredients === 'string') {
|
if (typeof rawIngredients === 'string') {
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue