mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-18 07:09:40 +02:00
feat(auth): add SessionExpiredBanner to all remaining web apps
Added to: clock, photos, storage, mukke, planta, picture, skilltree, nutriphi, chat. Now all 13 web apps show a re-login banner when token refresh permanently fails. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
90c438e267
commit
bf7517d24d
23 changed files with 842 additions and 19 deletions
|
|
@ -1,5 +1,5 @@
|
|||
import { describe, it, expect } from 'vitest';
|
||||
import { parseMealInput, formatParsedMealPreview } from './meal-parser';
|
||||
import { parseMealInput, formatParsedMealPreview, parseFoodItems } from './meal-parser';
|
||||
|
||||
describe('parseMealInput', () => {
|
||||
it('should parse food description', () => {
|
||||
|
|
@ -62,6 +62,60 @@ describe('parseMealInput', () => {
|
|||
});
|
||||
});
|
||||
|
||||
describe('parseFoodItems', () => {
|
||||
it('should parse "200g Reis, 2 Eier, 1 Scheibe Brot" into 3 items with amounts', () => {
|
||||
const items = parseFoodItems('200g Reis, 2 Eier, 1 Scheibe Brot');
|
||||
expect(items).toHaveLength(3);
|
||||
|
||||
expect(items[0]).toEqual({ amount: 200, unit: 'g', name: 'Reis' });
|
||||
expect(items[1]).toEqual({ amount: 2, name: 'Eier' });
|
||||
expect(items[2]).toEqual({ amount: 1, unit: 'Scheibe', name: 'Brot' });
|
||||
});
|
||||
|
||||
it('should parse "Spaghetti Bolognese" as 1 item with no amount', () => {
|
||||
const items = parseFoodItems('Spaghetti Bolognese');
|
||||
expect(items).toHaveLength(1);
|
||||
expect(items[0]).toEqual({ name: 'Spaghetti Bolognese' });
|
||||
});
|
||||
|
||||
it('should parse "2 Eier, Toast, Orangensaft" into 3 items', () => {
|
||||
const items = parseFoodItems('2 Eier, Toast, Orangensaft');
|
||||
expect(items).toHaveLength(3);
|
||||
|
||||
expect(items[0]).toEqual({ amount: 2, name: 'Eier' });
|
||||
expect(items[1]).toEqual({ name: 'Toast' });
|
||||
expect(items[2]).toEqual({ name: 'Orangensaft' });
|
||||
});
|
||||
|
||||
it('should handle fractions like 1/2', () => {
|
||||
const items = parseFoodItems('1/2 Tasse Milch');
|
||||
expect(items).toHaveLength(1);
|
||||
expect(items[0]).toEqual({ amount: 0.5, unit: 'Tasse', name: 'Milch' });
|
||||
});
|
||||
|
||||
it('should return empty array for empty input', () => {
|
||||
expect(parseFoodItems('')).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('parseMealInput with foodItems', () => {
|
||||
it('should include foodItems in parsed result', () => {
|
||||
const result = parseMealInput('200g Reis, 2 Eier, 1 Scheibe Brot');
|
||||
expect(result.foodItems).toHaveLength(3);
|
||||
expect(result.foodItems[0]).toEqual({ amount: 200, unit: 'g', name: 'Reis' });
|
||||
});
|
||||
|
||||
it('should parse "2 Eier, Toast, Orangensaft Frühstück" with 3 items and breakfast type', () => {
|
||||
const result = parseMealInput('2 Eier, Toast, Orangensaft Frühstück');
|
||||
expect(result.mealType).toBe('breakfast');
|
||||
expect(result.mealTypeExplicit).toBe(true);
|
||||
expect(result.foodItems).toHaveLength(3);
|
||||
expect(result.foodItems[0]).toEqual({ amount: 2, name: 'Eier' });
|
||||
expect(result.foodItems[1]).toEqual({ name: 'Toast' });
|
||||
expect(result.foodItems[2]).toEqual({ name: 'Orangensaft' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('formatParsedMealPreview', () => {
|
||||
it('should show meal type', () => {
|
||||
const parsed = parseMealInput('Toast Frühstück');
|
||||
|
|
@ -80,4 +134,10 @@ describe('formatParsedMealPreview', () => {
|
|||
const preview = formatParsedMealPreview(parsed);
|
||||
expect(preview).not.toContain('automatisch');
|
||||
});
|
||||
|
||||
it('should show items count in preview', () => {
|
||||
const parsed = parseMealInput('200g Reis, 2 Eier, 1 Scheibe Brot Mittagessen');
|
||||
const preview = formatParsedMealPreview(parsed);
|
||||
expect(preview).toContain('🥚 3 items');
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -15,10 +15,17 @@ import type { MealType } from '@nutriphi/shared';
|
|||
import { suggestMealType } from '@nutriphi/shared';
|
||||
import type { ParserLocale } from '@manacore/shared-utils';
|
||||
|
||||
export interface FoodItem {
|
||||
amount?: number;
|
||||
unit?: string;
|
||||
name: string;
|
||||
}
|
||||
|
||||
export interface ParsedMeal {
|
||||
description: string;
|
||||
mealType: MealType;
|
||||
mealTypeExplicit: boolean; // Was meal type explicitly mentioned?
|
||||
foodItems: FoodItem[];
|
||||
}
|
||||
|
||||
// Meal type patterns per locale
|
||||
|
|
@ -109,6 +116,62 @@ const AUTO_HINT_BY_LOCALE: Record<ParserLocale, string> = {
|
|||
it: 'automatico',
|
||||
};
|
||||
|
||||
// Units recognized by the parser
|
||||
const UNITS_PATTERN =
|
||||
/^(g|kg|mg|ml|l|dl|cl|Stück|Scheibe|Scheiben|Tasse|Tassen|EL|TL|pieces?|cups?|slices?|oz|lb)\b/i;
|
||||
|
||||
// Amount pattern: integers, decimals, or fractions like 1/2
|
||||
const AMOUNT_PATTERN = /^(\d+\/\d+|\d+(?:[.,]\d+)?)/;
|
||||
|
||||
/**
|
||||
* Parse a single food item string into structured FoodItem
|
||||
*/
|
||||
function parseSingleFoodItem(text: string): FoodItem {
|
||||
let remaining = text.trim();
|
||||
|
||||
// Try to extract amount
|
||||
let amount: number | undefined;
|
||||
const amountMatch = remaining.match(AMOUNT_PATTERN);
|
||||
if (amountMatch) {
|
||||
const raw = amountMatch[1];
|
||||
if (raw.includes('/')) {
|
||||
const [num, den] = raw.split('/');
|
||||
amount = parseInt(num, 10) / parseInt(den, 10);
|
||||
} else {
|
||||
amount = parseFloat(raw.replace(',', '.'));
|
||||
}
|
||||
remaining = remaining.slice(amountMatch[0].length).trim();
|
||||
}
|
||||
|
||||
// Try to extract unit (only if we found an amount, or unit is at the start)
|
||||
let unit: string | undefined;
|
||||
const unitMatch = remaining.match(UNITS_PATTERN);
|
||||
if (unitMatch) {
|
||||
unit = unitMatch[1];
|
||||
remaining = remaining.slice(unitMatch[0].length).trim();
|
||||
}
|
||||
|
||||
return {
|
||||
...(amount !== undefined && { amount }),
|
||||
...(unit !== undefined && { unit }),
|
||||
name: remaining,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse a description string into individual food items.
|
||||
* Splits on commas and parses each segment for amount, unit, and name.
|
||||
*/
|
||||
export function parseFoodItems(text: string): FoodItem[] {
|
||||
if (!text.trim()) return [];
|
||||
|
||||
const segments = text
|
||||
.split(',')
|
||||
.map((s) => s.trim())
|
||||
.filter(Boolean);
|
||||
return segments.map(parseSingleFoodItem);
|
||||
}
|
||||
|
||||
function extractMealType(
|
||||
text: string,
|
||||
locale: ParserLocale = 'de'
|
||||
|
|
@ -138,6 +201,9 @@ export function parseMealInput(input: string, locale: ParserLocale = 'de'): Pars
|
|||
// Clean up description
|
||||
const description = text.replace(/\s+/g, ' ').trim();
|
||||
|
||||
// Parse food items from description
|
||||
const foodItems = parseFoodItems(description);
|
||||
|
||||
// Use explicit meal type or auto-detect based on time of day
|
||||
const mealType = mealTypeResult.mealType || suggestMealType();
|
||||
|
||||
|
|
@ -145,6 +211,7 @@ export function parseMealInput(input: string, locale: ParserLocale = 'de'): Pars
|
|||
description,
|
||||
mealType,
|
||||
mealTypeExplicit: !!mealTypeResult.mealType,
|
||||
foodItems,
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -161,5 +228,9 @@ export function formatParsedMealPreview(parsed: ParsedMeal, locale: ParserLocale
|
|||
parts.push(`(${AUTO_HINT_BY_LOCALE[locale]})`);
|
||||
}
|
||||
|
||||
if (parsed.foodItems.length > 0) {
|
||||
parts.push(`🥚 ${parsed.foodItems.length} items`);
|
||||
}
|
||||
|
||||
return parts.join(' ');
|
||||
}
|
||||
|
|
|
|||
30
apps/nutriphi/apps/web/src/lib/utils/syntax-help.ts
Normal file
30
apps/nutriphi/apps/web/src/lib/utils/syntax-help.ts
Normal file
|
|
@ -0,0 +1,30 @@
|
|||
/**
|
||||
* NutriPhi-specific syntax help patterns
|
||||
*/
|
||||
import type { SyntaxGroup } from '@manacore/shared-ui';
|
||||
|
||||
export const NUTRIPHI_SYNTAX: SyntaxGroup[] = [
|
||||
{
|
||||
title: 'Mahlzeiten',
|
||||
items: [
|
||||
{
|
||||
pattern: 'Mahlzeittyp',
|
||||
description: 'Art der Mahlzeit',
|
||||
examples: ['Frühstück', 'Mittagessen', 'Abendessen', 'Snack'],
|
||||
color: 'success',
|
||||
},
|
||||
{
|
||||
pattern: 'Mengen',
|
||||
description: 'Mengenangaben',
|
||||
examples: ['200g Reis', '2 Eier', '1 Scheibe Brot', '100ml Milch'],
|
||||
color: 'accent',
|
||||
},
|
||||
{
|
||||
pattern: 'Komma-Liste',
|
||||
description: 'Mehrere Zutaten mit Komma trennen',
|
||||
examples: ['Reis, Hähnchen, Brokkoli'],
|
||||
color: 'primary',
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
Loading…
Add table
Add a link
Reference in a new issue