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

@ -0,0 +1,55 @@
/**
* Todo-specific syntax help patterns for InputBar help modal
*/
import type { SyntaxGroup } from '@manacore/shared-ui';
export const TODO_SYNTAX: SyntaxGroup[] = [
{
title: 'Aufgaben',
items: [
{
pattern: 'Priorität',
description: 'Dringlichkeit festlegen',
examples: [
{ text: '!!!', label: 'dringend', color: 'error' },
{ text: '!!', label: 'hoch', color: 'warning' },
{ text: 'normal', label: 'normal', color: 'warning-soft' },
{ text: 'später', label: 'niedrig', color: 'success' },
],
color: 'error',
},
{
pattern: '@Projekt',
description: 'Projekt zuweisen',
examples: ['@Arbeit', '@Privat', '@Einkauf'],
color: 'success',
},
{
pattern: 'Wiederholung',
description: 'Wiederkehrende Aufgabe',
examples: ['täglich', 'wöchentlich', 'jeden Montag', 'monatlich'],
color: 'warning-soft',
},
{
pattern: 'Subtasks',
description: 'Unteraufgaben mit Doppelpunkt + Komma',
examples: ['Einkaufen: Milch, Brot, Eier'],
color: 'accent',
},
],
},
];
export const TODO_LIVE_EXAMPLE = {
text: 'Einkaufen: Milch, Brot morgen !! @Privat #wichtig',
highlights: [
{ type: 'text' as const, content: 'Einkaufen: Milch, Brot ' },
{ type: 'date' as const, content: 'morgen' },
{ type: 'text' as const, content: ' ' },
{ type: 'priority' as const, content: '!!' },
{ type: 'text' as const, content: ' ' },
{ type: 'reference' as const, content: '@Privat' },
{ type: 'text' as const, content: ' ' },
{ type: 'tag' as const, content: '#wichtig' },
],
};

View file

@ -73,6 +73,53 @@ describe('parseTaskInput', () => {
const result = parseTaskInput('#arbeit #privat');
expect(result.labelNames).toEqual(['arbeit', 'privat']);
});
it('should parse recurrence "täglich"', () => {
const result = parseTaskInput('Standup täglich 9 Uhr');
expect(result.recurrenceRule).toBe('FREQ=DAILY');
expect(result.title).toBe('Standup');
});
it('should parse recurrence "jeden Montag"', () => {
const result = parseTaskInput('Wochenbericht jeden Montag');
expect(result.recurrenceRule).toBe('FREQ=WEEKLY;BYDAY=MO');
});
it('should parse recurrence "wöchentlich"', () => {
const result = parseTaskInput('Review wöchentlich @Arbeit');
expect(result.recurrenceRule).toBe('FREQ=WEEKLY');
expect(result.projectName).toBe('Arbeit');
});
it('should have no recurrence for normal input', () => {
const result = parseTaskInput('Einfache Aufgabe');
expect(result.recurrenceRule).toBeUndefined();
});
it('should parse subtasks "Einkaufen: Milch, Brot, Eier"', () => {
const result = parseTaskInput('Einkaufen: Milch, Brot, Eier');
expect(result.title).toBe('Einkaufen');
expect(result.subtasks).toEqual(['Milch', 'Brot', 'Eier']);
});
it('should parse subtasks with semicolons', () => {
const result = parseTaskInput('Aufräumen: Küche; Bad; Wohnzimmer');
expect(result.title).toBe('Aufräumen');
expect(result.subtasks).toEqual(['Küche', 'Bad', 'Wohnzimmer']);
});
it('should not parse subtasks with single item', () => {
const result = parseTaskInput('Note: important thing');
expect(result.subtasks).toBeUndefined();
});
it('should parse subtasks with other fields', () => {
const result = parseTaskInput('Einkaufen: Milch, Brot morgen !! @Privat');
expect(result.title).toBe('Einkaufen');
expect(result.subtasks).toEqual(['Milch', 'Brot']);
expect(result.priority).toBe('high');
expect(result.projectName).toBe('Privat');
});
});
describe('resolveTaskIds', () => {

View file

@ -9,6 +9,7 @@
import {
parseBaseInput,
extractAtReference,
extractRecurrence,
combineDateAndTime,
formatDatePreview,
formatTimePreview,
@ -22,6 +23,8 @@ export interface ParsedTask {
priority?: TaskPriority;
projectName?: string;
labelNames: string[];
recurrenceRule?: string;
subtasks?: string[];
}
interface Project {
@ -40,6 +43,8 @@ export interface ParsedTaskWithIds {
priority?: TaskPriority;
projectId?: string;
labelIds: string[];
recurrenceRule?: string;
subtasks?: string[];
}
// Priority keyword translations per locale
@ -54,6 +59,31 @@ const PRIORITY_KEYWORDS: Record<
it: { urgent: 'urgente', high: 'importante', medium: 'normale', low: 'dopo' },
};
/**
* Extract subtasks from "Title: item1, item2, item3" pattern
*/
function extractSubtasks(text: string): { title: string; subtasks?: string[] } {
// Match "Title: list" where list has commas or semicolons
const colonIndex = text.indexOf(':');
if (colonIndex === -1 || colonIndex < 2) return { title: text };
const beforeColon = text.substring(0, colonIndex).trim();
const afterColon = text.substring(colonIndex + 1).trim();
if (!afterColon) return { title: text };
// Split by comma or semicolon
const items = afterColon
.split(/[,;]/)
.map((s) => s.trim())
.filter((s) => s.length > 0);
// Only treat as subtasks if there are at least 2 items
if (items.length < 2) return { title: text };
return { title: beforeColon, subtasks: items };
}
/**
* Build locale-aware priority patterns
*/
@ -99,7 +129,12 @@ function extractPriority(
export function parseTaskInput(input: string, locale: ParserLocale = 'de'): ParsedTask {
let text = input.trim();
// Extract priority first (task-specific)
// Extract recurrence (before priority, since "jeden Tag" shouldn't be confused)
const recurrenceResult = extractRecurrence(text, locale);
text = recurrenceResult.remaining;
const recurrenceRule = recurrenceResult.value;
// Extract priority (task-specific)
const priorityResult = extractPriority(text, locale);
text = priorityResult.remaining;
const priority = priorityResult.priority;
@ -115,12 +150,17 @@ export function parseTaskInput(input: string, locale: ParserLocale = 'de'): Pars
// Combine date and time
const dueDate = combineDateAndTime(base.date, base.time);
// Check for subtask pattern "Title: item1, item2, item3"
const subtaskResult = extractSubtasks(base.title);
return {
title: base.title,
title: subtaskResult.title,
dueDate,
priority,
projectName,
labelNames: base.tagNames,
recurrenceRule,
subtasks: subtaskResult.subtasks,
};
}
@ -159,6 +199,8 @@ export function resolveTaskIds(
priority: parsed.priority,
projectId,
labelIds,
recurrenceRule: parsed.recurrenceRule,
subtasks: parsed.subtasks,
};
}
@ -199,6 +241,14 @@ export function formatParsedTaskPreview(parsed: ParsedTask, locale: ParserLocale
parts.push(`📁 ${parsed.projectName}`);
}
if (parsed.recurrenceRule) {
parts.push(`🔄 ${parsed.recurrenceRule}`);
}
if (parsed.subtasks && parsed.subtasks.length > 0) {
parts.push(`📋 ${parsed.subtasks.length} Subtasks`);
}
if (parsed.labelNames.length > 0) {
parts.push(`🏷️ ${parsed.labelNames.join(', ')}`);
}