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

@ -53,6 +53,51 @@ describe('parsePlantInput', () => {
});
});
describe('parsePlantInput - care actions', () => {
it('should parse "Monstera gegossen" as watered', () => {
const result = parsePlantInput('Monstera gegossen');
expect(result.name).toBe('Monstera');
expect(result.action).toBe('watered');
});
it('should parse "Ficus umgetopft heute" as repotted with date', () => {
const result = parsePlantInput('Ficus umgetopft heute');
expect(result.name).toBe('Ficus');
expect(result.action).toBe('repotted');
expect(result.acquiredAt).toBeDefined();
});
it('should parse "Rose pruned" in English as pruned', () => {
const result = parsePlantInput('Rose pruned', 'en');
expect(result.name).toBe('Rose');
expect(result.action).toBe('pruned');
});
it('should have no action for plain "Monstera"', () => {
const result = parsePlantInput('Monstera');
expect(result.name).toBe('Monstera');
expect(result.action).toBeUndefined();
});
it('should parse "Orchidee gedüngt" as fertilized', () => {
const result = parsePlantInput('Orchidee gedüngt');
expect(result.name).toBe('Orchidee');
expect(result.action).toBe('fertilized');
});
it('should parse "Ficus geschnitten" as pruned', () => {
const result = parsePlantInput('Ficus geschnitten');
expect(result.name).toBe('Ficus');
expect(result.action).toBe('pruned');
});
it('should parse "gewässert" as watered (alternative DE word)', () => {
const result = parsePlantInput('Monstera gewässert');
expect(result.name).toBe('Monstera');
expect(result.action).toBe('watered');
});
});
describe('resolvePlantData', () => {
it('should produce ISO date string', () => {
const parsed = parsePlantInput('Ficus heute gekauft');
@ -86,4 +131,18 @@ describe('formatParsedPlantPreview', () => {
const parsed = parsePlantInput('Monstera');
expect(formatParsedPlantPreview(parsed)).toBe('');
});
it('should format care action in preview', () => {
const parsed = parsePlantInput('Monstera gegossen');
const preview = formatParsedPlantPreview(parsed);
expect(preview).toContain('💧');
expect(preview).toContain('Gegossen');
});
it('should format care action with English locale', () => {
const parsed = parsePlantInput('Rose watered', 'en');
const preview = formatParsedPlantPreview(parsed, 'en');
expect(preview).toContain('💧');
expect(preview).toContain('Watered');
});
});

View file

@ -20,10 +20,13 @@ import {
type ParserLocale,
} from '@manacore/shared-utils';
export type CareAction = 'watered' | 'repotted' | 'fertilized' | 'pruned';
export interface ParsedPlant {
name: string;
acquiredAt?: Date;
tagNames: string[];
action?: CareAction;
}
export interface ParsedPlantWithIds {
@ -31,6 +34,79 @@ export interface ParsedPlantWithIds {
acquiredAt?: string;
}
// Care action patterns per locale
const CARE_ACTION_PATTERNS_BY_LOCALE: Record<
ParserLocale,
{ action: CareAction; pattern: RegExp }[]
> = {
de: [
{ action: 'watered', pattern: /\b(?:gegossen|gewässert)\b/i },
{ action: 'repotted', pattern: /\bumgetopft\b/i },
{ action: 'fertilized', pattern: /\bgedüngt\b/i },
{ action: 'pruned', pattern: /\b(?:geschnitten|gestutzt)\b/i },
],
en: [
{ action: 'watered', pattern: /\bwatered\b/i },
{ action: 'repotted', pattern: /\brepotted\b/i },
{ action: 'fertilized', pattern: /\bfertilized\b/i },
{ action: 'pruned', pattern: /\b(?:pruned|trimmed)\b/i },
],
fr: [
{ action: 'watered', pattern: /\barrosé\b/i },
{ action: 'repotted', pattern: /\brempoté\b/i },
{ action: 'fertilized', pattern: /\bfertilisé\b/i },
{ action: 'pruned', pattern: /\btaillé\b/i },
],
es: [
{ action: 'watered', pattern: /\bregado\b/i },
{ action: 'repotted', pattern: /\btrasplantado\b/i },
{ action: 'fertilized', pattern: /\bfertilizado\b/i },
{ action: 'pruned', pattern: /\bpodado\b/i },
],
it: [
{ action: 'watered', pattern: /\bannaffiato\b/i },
{ action: 'repotted', pattern: /\brinvasato\b/i },
{ action: 'fertilized', pattern: /\bfertilizzato\b/i },
{ action: 'pruned', pattern: /\bpotato\b/i },
],
};
const ACTION_LABELS: Record<CareAction, Record<ParserLocale, string>> = {
watered: { de: 'Gegossen', en: 'Watered', fr: 'Arrosé', es: 'Regado', it: 'Annaffiato' },
repotted: { de: 'Umgetopft', en: 'Repotted', fr: 'Rempoté', es: 'Trasplantado', it: 'Rinvasato' },
fertilized: {
de: 'Gedüngt',
en: 'Fertilized',
fr: 'Fertilisé',
es: 'Fertilizado',
it: 'Fertilizzato',
},
pruned: { de: 'Geschnitten', en: 'Pruned', fr: 'Taillé', es: 'Podado', it: 'Potato' },
};
const ACTION_EMOJIS: Record<CareAction, string> = {
watered: '💧',
repotted: '🌱',
fertilized: '🧪',
pruned: '✂️',
};
function extractCareAction(
text: string,
locale: ParserLocale = 'de'
): { action?: CareAction; remaining: string } {
const patterns = CARE_ACTION_PATTERNS_BY_LOCALE[locale];
for (const { action, pattern } of patterns) {
if (pattern.test(text)) {
return {
action,
remaining: text.replace(pattern, '').trim(),
};
}
}
return { action: undefined, remaining: text };
}
// Acquisition keywords per locale
const ACQUIRED_PATTERNS_BY_LOCALE: Record<ParserLocale, RegExp[]> = {
de: [/\bgekauft\b/i, /\bbekommen\b/i, /\berhalten\b/i, /\bgepflanzt\b/i],
@ -67,6 +143,10 @@ function extractAcquiredKeyword(
export function parsePlantInput(input: string, locale: ParserLocale = 'de'): ParsedPlant {
let text = input.trim();
// Extract care action BEFORE base parser so the action word is removed from title
const careResult = extractCareAction(text, locale);
text = careResult.remaining;
// Check for acquisition keywords
const acquiredResult = extractAcquiredKeyword(text, locale);
text = acquiredResult.remaining;
@ -86,6 +166,7 @@ export function parsePlantInput(input: string, locale: ParserLocale = 'de'): Par
name: base.title,
acquiredAt,
tagNames: base.tagNames,
action: careResult.action,
};
}
@ -105,6 +186,12 @@ export function resolvePlantData(parsed: ParsedPlant): ParsedPlantWithIds {
export function formatParsedPlantPreview(parsed: ParsedPlant, locale: ParserLocale = 'de'): string {
const parts: string[] = [];
if (parsed.action) {
const emoji = ACTION_EMOJIS[parsed.action];
const label = ACTION_LABELS[parsed.action][locale];
parts.push(`${emoji} ${label}`);
}
if (parsed.acquiredAt) {
parts.push(`📅 ${formatDatePreview(parsed.acquiredAt, locale)}`);
}

View file

@ -0,0 +1,24 @@
/**
* Planta-specific syntax help patterns
*/
import type { SyntaxGroup } from '@manacore/shared-ui';
export const PLANTA_SYNTAX: SyntaxGroup[] = [
{
title: 'Pflanzen',
items: [
{
pattern: 'Pflege',
description: 'Pflege-Aktion loggen',
examples: ['Monstera gegossen', 'Ficus umgetopft', 'Rose gedüngt'],
color: 'success',
},
{
pattern: 'Erworben',
description: 'Erwerbsdatum angeben',
examples: ['gekauft', 'gepflanzt', 'bekommen'],
color: 'accent',
},
],
},
];