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:
Till JS 2026-03-24 22:35:13 +01:00
parent 90c438e267
commit bf7517d24d
23 changed files with 842 additions and 19 deletions

View file

@ -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');
});
});

View file

@ -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(' ');
}

View 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',
},
],
},
];