mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-14 19:01:08 +02:00
feat(app-registry): wire up 4 modules + 7 routes + tier-patch validator
Resolves the cross-cutting drift that the app-registry sanity-test was
silently catching but BRANDING_ONLY exceptions papered over.
App-registry wiring:
- Register augur, broadcasts, invoices, timeline as workbench cards.
- Resolve agents↔ai-agents naming drift: workbench id is now `agents`
(matches MANA_APPS + the /agents route URL); folder stays `ai-agents`
for grouping with other ai-* modules.
Broadcast→broadcasts unification:
- module.config appId, MANA_APPS id, APP_ICONS key, all route appIds,
and the redundant APP_URL_OVERRIDES entry — all aligned with the
earlier folder rename so nothing diverges anymore.
Top-level routes for workbench-only modules:
- /goals, /myday, /kontext, /rituals, /automations, /activity — thin
RoutePage wrappers around the existing module ListViews.
- /timeline becomes a real module (ListView extracted from the route),
route shrinks to a 12-line wrapper.
Food unarchive:
- packages/shared-branding/src/mana-apps.ts: remove `archived: true`
from food entry. The module is fully wired (registered, synced,
routed, with AI tools); the flag was outdated.
i18n cleanup:
- Rename ai-agents → agents key in all 5 apps locales.
- Drop dead "observatory" key from all 5 nav locales (route folder was
removed in 7bca16dfa).
New CI guard — scripts/validate-tier-patches.mjs:
- Scans for `LOCAL TIER PATCH — revert before release` markers.
- Default: informational list (does not fail).
- Strict mode (MANA_TIER_PATCH_STRICT=1) for release/RC pipeline.
- Wired into validate:all.
Spec update:
- registry.spec.ts WORKBENCH_ONLY/BRANDING_ONLY: documented Settings
family + AI Studio surfaces + intentionally-internal modules so the
drift guard fires only on real drift.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
8a5fad34df
commit
fa299e3bf9
41 changed files with 812 additions and 556 deletions
|
|
@ -84,6 +84,10 @@ import {
|
||||||
FilmStrip,
|
FilmStrip,
|
||||||
Hourglass,
|
Hourglass,
|
||||||
HeartHalf,
|
HeartHalf,
|
||||||
|
Eye,
|
||||||
|
Megaphone,
|
||||||
|
Receipt,
|
||||||
|
ClockCounterClockwise,
|
||||||
} from '@mana/shared-icons';
|
} from '@mana/shared-icons';
|
||||||
|
|
||||||
// ── Apps with entity capabilities ───────────────────────────
|
// ── Apps with entity capabilities ───────────────────────────
|
||||||
|
|
@ -1113,7 +1117,9 @@ registerApp({
|
||||||
});
|
});
|
||||||
|
|
||||||
registerApp({
|
registerApp({
|
||||||
id: 'ai-agents',
|
// Public id matches MANA_APPS + the route URL `/agents`. The module
|
||||||
|
// folder is named `ai-agents` for grouping with other ai-* modules.
|
||||||
|
id: 'agents',
|
||||||
name: 'AI Agents',
|
name: 'AI Agents',
|
||||||
color: '#8B5CF6',
|
color: '#8B5CF6',
|
||||||
icon: Flag,
|
icon: Flag,
|
||||||
|
|
@ -1492,3 +1498,71 @@ registerApp({
|
||||||
return quiz.id;
|
return quiz.id;
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
registerApp({
|
||||||
|
id: 'augur',
|
||||||
|
name: 'Augur',
|
||||||
|
color: '#7c3aed',
|
||||||
|
icon: Eye,
|
||||||
|
views: {
|
||||||
|
// Witness/Oracle modes live inside the ListView; detail (/augur/entry/[id])
|
||||||
|
// and recap (/augur/recap) navigate via goto().
|
||||||
|
list: { load: () => import('$lib/modules/augur/ListView.svelte') },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
registerApp({
|
||||||
|
id: 'broadcasts',
|
||||||
|
name: 'Broadcasts',
|
||||||
|
color: '#6366f1',
|
||||||
|
icon: Megaphone,
|
||||||
|
views: {
|
||||||
|
// Detail (/broadcasts/[id]), new (/broadcasts/new) and settings live
|
||||||
|
// as SvelteKit routes; the workbench card hosts the ListView root.
|
||||||
|
list: { load: () => import('$lib/modules/broadcasts/ListView.svelte') },
|
||||||
|
},
|
||||||
|
contextMenuActions: [
|
||||||
|
{
|
||||||
|
id: 'new-campaign',
|
||||||
|
label: 'Neue Kampagne',
|
||||||
|
icon: Plus,
|
||||||
|
action: () => {
|
||||||
|
window.location.href = '/broadcasts/new';
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
registerApp({
|
||||||
|
id: 'invoices',
|
||||||
|
name: 'Rechnungen',
|
||||||
|
color: '#059669',
|
||||||
|
icon: Receipt,
|
||||||
|
views: {
|
||||||
|
// Detail (/invoices/[id]), new (/invoices/new) and settings live as
|
||||||
|
// SvelteKit routes; the workbench card hosts the ListView root.
|
||||||
|
list: { load: () => import('$lib/modules/invoices/ListView.svelte') },
|
||||||
|
},
|
||||||
|
contextMenuActions: [
|
||||||
|
{
|
||||||
|
id: 'new-invoice',
|
||||||
|
label: 'Neue Rechnung',
|
||||||
|
icon: Plus,
|
||||||
|
action: () => {
|
||||||
|
window.location.href = '/invoices/new';
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
registerApp({
|
||||||
|
id: 'timeline',
|
||||||
|
name: 'Timeline',
|
||||||
|
color: '#f59e0b',
|
||||||
|
icon: ClockCounterClockwise,
|
||||||
|
views: {
|
||||||
|
// Cross-module read-only view — reads timeBlocks owned by core.
|
||||||
|
// /timeline/analytics navigates via goto() from the route page.
|
||||||
|
list: { load: () => import('$lib/modules/timeline/ListView.svelte') },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
|
||||||
|
|
@ -45,7 +45,7 @@ export const APP_CATEGORY_MAP: Record<string, AppCategory> = {
|
||||||
// AI Workbench — the 6 new AI feature apps + companion chat
|
// AI Workbench — the 6 new AI feature apps + companion chat
|
||||||
companion: 'ai',
|
companion: 'ai',
|
||||||
'ai-missions': 'ai',
|
'ai-missions': 'ai',
|
||||||
'ai-agents': 'ai',
|
agents: 'ai',
|
||||||
'ai-workbench': 'ai',
|
'ai-workbench': 'ai',
|
||||||
'ai-policy': 'ai',
|
'ai-policy': 'ai',
|
||||||
'ai-insights': 'ai',
|
'ai-insights': 'ai',
|
||||||
|
|
|
||||||
|
|
@ -690,7 +690,7 @@ export const MODULE_HELP: Record<string, ModuleHelp> = {
|
||||||
'Der Debug-Log hilft zu verstehen warum die AI bestimmte Entscheidungen trifft',
|
'Der Debug-Log hilft zu verstehen warum die AI bestimmte Entscheidungen trifft',
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
'ai-agents': {
|
agents: {
|
||||||
description:
|
description:
|
||||||
'Benannte AI-Personas mit eigenem System-Prompt, Policy und Gedächtnis. Jeder Agent kann eigene Missionen ausführen.',
|
'Benannte AI-Personas mit eigenem System-Prompt, Policy und Gedächtnis. Jeder Agent kann eigene Missionen ausführen.',
|
||||||
features: [
|
features: [
|
||||||
|
|
|
||||||
|
|
@ -14,7 +14,36 @@ import { getAllApps } from './registry';
|
||||||
* the kind of mismatch that produced the silent inventar↔inventory bug
|
* the kind of mismatch that produced the silent inventar↔inventory bug
|
||||||
* before commit 45790ffbb.
|
* before commit 45790ffbb.
|
||||||
*/
|
*/
|
||||||
const WORKBENCH_ONLY = new Set(['automations', 'playground']);
|
const WORKBENCH_ONLY = new Set([
|
||||||
|
// Dev / internal tools — never meant for MANA_APPS.
|
||||||
|
'automations',
|
||||||
|
'playground',
|
||||||
|
'admin',
|
||||||
|
'complexity',
|
||||||
|
'feedback',
|
||||||
|
// Settings family — surfaced as workbench cards but conceptually
|
||||||
|
// part of /settings, not standalone MANA_APPS entries.
|
||||||
|
'settings',
|
||||||
|
'themes',
|
||||||
|
'profile',
|
||||||
|
'credits',
|
||||||
|
'api-keys',
|
||||||
|
'help',
|
||||||
|
'spiral',
|
||||||
|
// User-facing modules that don't yet have MANA_APPS branding.
|
||||||
|
// Add them to MANA_APPS when they ship a marketing surface.
|
||||||
|
'kontext',
|
||||||
|
'rituals',
|
||||||
|
'wishes',
|
||||||
|
// AI Studio surfaces. Open question whether to expose each one in
|
||||||
|
// MANA_APPS or consolidate into a single "AI Studio" branding entry.
|
||||||
|
// Keeping them workbench-only until that decision lands.
|
||||||
|
'ai-missions',
|
||||||
|
'ai-workbench',
|
||||||
|
'ai-policy',
|
||||||
|
'ai-insights',
|
||||||
|
'ai-health',
|
||||||
|
]);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Apps that intentionally exist in MANA_APPS but are NOT in the workbench
|
* Apps that intentionally exist in MANA_APPS but are NOT in the workbench
|
||||||
|
|
@ -39,11 +68,6 @@ const BRANDING_ONLY = new Set([
|
||||||
// gallery as "Coming Soon" hints.
|
// gallery as "Coming Soon" hints.
|
||||||
'wisekeep',
|
'wisekeep',
|
||||||
'mail',
|
'mail',
|
||||||
'events',
|
|
||||||
// Status 'beta' but the workbench integration is still pending. Move
|
|
||||||
// out of this list once the apps.ts entry exists.
|
|
||||||
'guides',
|
|
||||||
'who',
|
|
||||||
]);
|
]);
|
||||||
|
|
||||||
describe('app registry ↔ MANA_APPS consistency', () => {
|
describe('app registry ↔ MANA_APPS consistency', () => {
|
||||||
|
|
|
||||||
|
|
@ -35,7 +35,7 @@ import NewsUnreadWidget from '$lib/modules/news/widgets/NewsUnreadWidget.svelte'
|
||||||
import ArticlesUnreadWidget from '$lib/modules/articles/widgets/ArticlesUnreadWidget.svelte';
|
import ArticlesUnreadWidget from '$lib/modules/articles/widgets/ArticlesUnreadWidget.svelte';
|
||||||
import BodyStatsWidget from '$lib/modules/body/widgets/BodyStatsWidget.svelte';
|
import BodyStatsWidget from '$lib/modules/body/widgets/BodyStatsWidget.svelte';
|
||||||
import InvoicesOpenWidget from '$lib/modules/invoices/widgets/InvoicesOpenWidget.svelte';
|
import InvoicesOpenWidget from '$lib/modules/invoices/widgets/InvoicesOpenWidget.svelte';
|
||||||
import BroadcastsWidget from '$lib/modules/broadcast/widgets/BroadcastsWidget.svelte';
|
import BroadcastsWidget from '$lib/modules/broadcasts/widgets/BroadcastsWidget.svelte';
|
||||||
import DayTimelineWidget from './widgets/DayTimelineWidget.svelte';
|
import DayTimelineWidget from './widgets/DayTimelineWidget.svelte';
|
||||||
import ActivityFeedWidget from './widgets/ActivityFeedWidget.svelte';
|
import ActivityFeedWidget from './widgets/ActivityFeedWidget.svelte';
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -87,7 +87,7 @@ import type {
|
||||||
LocalCampaign,
|
LocalCampaign,
|
||||||
LocalBroadcastTemplate,
|
LocalBroadcastTemplate,
|
||||||
LocalBroadcastSettings,
|
LocalBroadcastSettings,
|
||||||
} from '../../modules/broadcast/types';
|
} from '../../modules/broadcasts/types';
|
||||||
import type { LocalArticle, LocalHighlight } from '../../modules/articles/types';
|
import type { LocalArticle, LocalHighlight } from '../../modules/articles/types';
|
||||||
import type { LocalMeImage } from '../../modules/profile/types';
|
import type { LocalMeImage } from '../../modules/profile/types';
|
||||||
import type { LocalWardrobeGarment, LocalWardrobeOutfit } from '../../modules/wardrobe/types';
|
import type { LocalWardrobeGarment, LocalWardrobeOutfit } from '../../modules/wardrobe/types';
|
||||||
|
|
|
||||||
|
|
@ -102,7 +102,7 @@ import { profileModuleConfig } from '$lib/modules/profile/module.config';
|
||||||
import { libraryModuleConfig } from '$lib/modules/library/module.config';
|
import { libraryModuleConfig } from '$lib/modules/library/module.config';
|
||||||
import { articlesModuleConfig } from '$lib/modules/articles/module.config';
|
import { articlesModuleConfig } from '$lib/modules/articles/module.config';
|
||||||
import { invoicesModuleConfig } from '$lib/modules/invoices/module.config';
|
import { invoicesModuleConfig } from '$lib/modules/invoices/module.config';
|
||||||
import { broadcastModuleConfig } from '$lib/modules/broadcast/module.config';
|
import { broadcastModuleConfig } from '$lib/modules/broadcasts/module.config';
|
||||||
import { wetterModuleConfig } from '$lib/modules/wetter/module.config';
|
import { wetterModuleConfig } from '$lib/modules/wetter/module.config';
|
||||||
import { websiteModuleConfig } from '$lib/modules/website/module.config';
|
import { websiteModuleConfig } from '$lib/modules/website/module.config';
|
||||||
import { wardrobeModuleConfig } from '$lib/modules/wardrobe/module.config';
|
import { wardrobeModuleConfig } from '$lib/modules/wardrobe/module.config';
|
||||||
|
|
|
||||||
|
|
@ -45,7 +45,7 @@ import { wetterTools } from '$lib/modules/wetter/tools';
|
||||||
import { quizTools } from '$lib/modules/quiz/tools';
|
import { quizTools } from '$lib/modules/quiz/tools';
|
||||||
import { invoicesTools } from '$lib/modules/invoices/tools';
|
import { invoicesTools } from '$lib/modules/invoices/tools';
|
||||||
import { libraryTools } from '$lib/modules/library/tools';
|
import { libraryTools } from '$lib/modules/library/tools';
|
||||||
import { broadcastTools } from '$lib/modules/broadcast/tools';
|
import { broadcastTools } from '$lib/modules/broadcasts/tools';
|
||||||
import { websiteTools } from '$lib/modules/website/tools';
|
import { websiteTools } from '$lib/modules/website/tools';
|
||||||
import { writingTools } from '$lib/modules/writing/tools';
|
import { writingTools } from '$lib/modules/writing/tools';
|
||||||
import { comicTools } from '$lib/modules/comic/tools';
|
import { comicTools } from '$lib/modules/comic/tools';
|
||||||
|
|
|
||||||
|
|
@ -51,7 +51,7 @@
|
||||||
"activity": "Aktivität",
|
"activity": "Aktivität",
|
||||||
"companion": "Begleiter",
|
"companion": "Begleiter",
|
||||||
"ai-missions": "KI-Missionen",
|
"ai-missions": "KI-Missionen",
|
||||||
"ai-agents": "KI-Agenten",
|
"agents": "KI-Agenten",
|
||||||
"ai-workbench": "KI-Werkbank",
|
"ai-workbench": "KI-Werkbank",
|
||||||
"rituals": "Rituale",
|
"rituals": "Rituale",
|
||||||
"ai-policy": "KI-Richtlinien",
|
"ai-policy": "KI-Richtlinien",
|
||||||
|
|
|
||||||
|
|
@ -51,7 +51,7 @@
|
||||||
"activity": "Activity",
|
"activity": "Activity",
|
||||||
"companion": "Companion",
|
"companion": "Companion",
|
||||||
"ai-missions": "AI Missions",
|
"ai-missions": "AI Missions",
|
||||||
"ai-agents": "AI Agents",
|
"agents": "AI Agents",
|
||||||
"ai-workbench": "AI Workbench",
|
"ai-workbench": "AI Workbench",
|
||||||
"rituals": "Rituals",
|
"rituals": "Rituals",
|
||||||
"ai-policy": "AI Policy",
|
"ai-policy": "AI Policy",
|
||||||
|
|
|
||||||
|
|
@ -51,7 +51,7 @@
|
||||||
"activity": "Actividad",
|
"activity": "Actividad",
|
||||||
"companion": "Compañero",
|
"companion": "Compañero",
|
||||||
"ai-missions": "Misiones IA",
|
"ai-missions": "Misiones IA",
|
||||||
"ai-agents": "Agentes IA",
|
"agents": "Agentes IA",
|
||||||
"ai-workbench": "Workbench IA",
|
"ai-workbench": "Workbench IA",
|
||||||
"rituals": "Rituales",
|
"rituals": "Rituales",
|
||||||
"ai-policy": "Políticas IA",
|
"ai-policy": "Políticas IA",
|
||||||
|
|
|
||||||
|
|
@ -51,7 +51,7 @@
|
||||||
"activity": "Activité",
|
"activity": "Activité",
|
||||||
"companion": "Compagnon",
|
"companion": "Compagnon",
|
||||||
"ai-missions": "Missions IA",
|
"ai-missions": "Missions IA",
|
||||||
"ai-agents": "Agents IA",
|
"agents": "Agents IA",
|
||||||
"ai-workbench": "Workbench IA",
|
"ai-workbench": "Workbench IA",
|
||||||
"rituals": "Rituels",
|
"rituals": "Rituels",
|
||||||
"ai-policy": "Règles IA",
|
"ai-policy": "Règles IA",
|
||||||
|
|
|
||||||
|
|
@ -51,7 +51,7 @@
|
||||||
"activity": "Attività",
|
"activity": "Attività",
|
||||||
"companion": "Compagno",
|
"companion": "Compagno",
|
||||||
"ai-missions": "Missioni IA",
|
"ai-missions": "Missioni IA",
|
||||||
"ai-agents": "Agenti IA",
|
"agents": "Agenti IA",
|
||||||
"ai-workbench": "Workbench IA",
|
"ai-workbench": "Workbench IA",
|
||||||
"rituals": "Rituali",
|
"rituals": "Rituali",
|
||||||
"ai-policy": "Regole IA",
|
"ai-policy": "Regole IA",
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,6 @@
|
||||||
"home": "Home",
|
"home": "Home",
|
||||||
"dashboard": "Dashboard",
|
"dashboard": "Dashboard",
|
||||||
"spiral": "Spiral",
|
"spiral": "Spiral",
|
||||||
"observatory": "Observatory",
|
|
||||||
"credits": "Credits",
|
"credits": "Credits",
|
||||||
"gifts": "Geschenke",
|
"gifts": "Geschenke",
|
||||||
"api_keys": "API Keys",
|
"api_keys": "API Keys",
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,6 @@
|
||||||
"home": "Home",
|
"home": "Home",
|
||||||
"dashboard": "Dashboard",
|
"dashboard": "Dashboard",
|
||||||
"spiral": "Spiral",
|
"spiral": "Spiral",
|
||||||
"observatory": "Observatory",
|
|
||||||
"credits": "Credits",
|
"credits": "Credits",
|
||||||
"gifts": "Gifts",
|
"gifts": "Gifts",
|
||||||
"api_keys": "API Keys",
|
"api_keys": "API Keys",
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,6 @@
|
||||||
"home": "Inicio",
|
"home": "Inicio",
|
||||||
"dashboard": "Panel",
|
"dashboard": "Panel",
|
||||||
"spiral": "Spiral",
|
"spiral": "Spiral",
|
||||||
"observatory": "Observatorio",
|
|
||||||
"credits": "Créditos",
|
"credits": "Créditos",
|
||||||
"gifts": "Regalos",
|
"gifts": "Regalos",
|
||||||
"api_keys": "API Keys",
|
"api_keys": "API Keys",
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,6 @@
|
||||||
"home": "Accueil",
|
"home": "Accueil",
|
||||||
"dashboard": "Tableau de bord",
|
"dashboard": "Tableau de bord",
|
||||||
"spiral": "Spiral",
|
"spiral": "Spiral",
|
||||||
"observatory": "Observatoire",
|
|
||||||
"credits": "Crédits",
|
"credits": "Crédits",
|
||||||
"gifts": "Cadeaux",
|
"gifts": "Cadeaux",
|
||||||
"api_keys": "Clés API",
|
"api_keys": "Clés API",
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,6 @@
|
||||||
"home": "Home",
|
"home": "Home",
|
||||||
"dashboard": "Dashboard",
|
"dashboard": "Dashboard",
|
||||||
"spiral": "Spiral",
|
"spiral": "Spiral",
|
||||||
"observatory": "Osservatorio",
|
|
||||||
"credits": "Crediti",
|
"credits": "Crediti",
|
||||||
"gifts": "Regali",
|
"gifts": "Regali",
|
||||||
"api_keys": "Chiavi API",
|
"api_keys": "Chiavi API",
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
import type { ModuleConfig } from '$lib/data/module-registry';
|
import type { ModuleConfig } from '$lib/data/module-registry';
|
||||||
|
|
||||||
export const broadcastModuleConfig: ModuleConfig = {
|
export const broadcastModuleConfig: ModuleConfig = {
|
||||||
appId: 'broadcast',
|
appId: 'broadcasts',
|
||||||
tables: [
|
tables: [
|
||||||
{ name: 'broadcastCampaigns' },
|
{ name: 'broadcastCampaigns' },
|
||||||
{ name: 'broadcastTemplates' },
|
{ name: 'broadcastTemplates' },
|
||||||
|
|
|
||||||
|
|
@ -80,7 +80,7 @@ export function toSettings(local: LocalBroadcastSettings): BroadcastSettings {
|
||||||
export function useAllCampaigns() {
|
export function useAllCampaigns() {
|
||||||
return useScopedLiveQuery(async () => {
|
return useScopedLiveQuery(async () => {
|
||||||
const rows = await scopedForModule<LocalCampaign, string>(
|
const rows = await scopedForModule<LocalCampaign, string>(
|
||||||
'broadcast',
|
'broadcasts',
|
||||||
'broadcastCampaigns'
|
'broadcastCampaigns'
|
||||||
).toArray();
|
).toArray();
|
||||||
const visible = rows.filter((r) => !r.deletedAt);
|
const visible = rows.filter((r) => !r.deletedAt);
|
||||||
|
|
@ -92,7 +92,7 @@ export function useAllCampaigns() {
|
||||||
export function useAllTemplates() {
|
export function useAllTemplates() {
|
||||||
return useScopedLiveQuery(async () => {
|
return useScopedLiveQuery(async () => {
|
||||||
const rows = await scopedForModule<LocalBroadcastTemplate, string>(
|
const rows = await scopedForModule<LocalBroadcastTemplate, string>(
|
||||||
'broadcast',
|
'broadcasts',
|
||||||
'broadcastTemplates'
|
'broadcastTemplates'
|
||||||
).toArray();
|
).toArray();
|
||||||
const visible = rows.filter((r) => !r.deletedAt);
|
const visible = rows.filter((r) => !r.deletedAt);
|
||||||
|
|
|
||||||
|
|
@ -70,7 +70,7 @@ export const broadcastCampaignsStore = {
|
||||||
|
|
||||||
await encryptRecord('broadcastCampaigns', newLocal);
|
await encryptRecord('broadcastCampaigns', newLocal);
|
||||||
await campaignTable.add(newLocal);
|
await campaignTable.add(newLocal);
|
||||||
emitDomainEvent('BroadcastCampaignCreated', 'broadcast', 'broadcastCampaigns', newLocal.id, {
|
emitDomainEvent('BroadcastCampaignCreated', 'broadcasts', 'broadcastCampaigns', newLocal.id, {
|
||||||
campaignId: newLocal.id,
|
campaignId: newLocal.id,
|
||||||
name: newLocal.name,
|
name: newLocal.name,
|
||||||
});
|
});
|
||||||
|
|
@ -142,7 +142,7 @@ export const broadcastCampaignsStore = {
|
||||||
status: 'scheduled' as CampaignStatus,
|
status: 'scheduled' as CampaignStatus,
|
||||||
scheduledAt,
|
scheduledAt,
|
||||||
});
|
});
|
||||||
emitDomainEvent('BroadcastCampaignScheduled', 'broadcast', 'broadcastCampaigns', id, {
|
emitDomainEvent('BroadcastCampaignScheduled', 'broadcasts', 'broadcastCampaigns', id, {
|
||||||
campaignId: id,
|
campaignId: id,
|
||||||
scheduledAt,
|
scheduledAt,
|
||||||
});
|
});
|
||||||
|
|
@ -159,7 +159,7 @@ export const broadcastCampaignsStore = {
|
||||||
status: 'cancelled' as CampaignStatus,
|
status: 'cancelled' as CampaignStatus,
|
||||||
scheduledAt: null,
|
scheduledAt: null,
|
||||||
});
|
});
|
||||||
emitDomainEvent('BroadcastCampaignCancelled', 'broadcast', 'broadcastCampaigns', id, {
|
emitDomainEvent('BroadcastCampaignCancelled', 'broadcasts', 'broadcastCampaigns', id, {
|
||||||
campaignId: id,
|
campaignId: id,
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
|
@ -198,7 +198,7 @@ export const broadcastCampaignsStore = {
|
||||||
await campaignTable.update(id, {
|
await campaignTable.update(id, {
|
||||||
deletedAt: new Date().toISOString(),
|
deletedAt: new Date().toISOString(),
|
||||||
});
|
});
|
||||||
emitDomainEvent('BroadcastCampaignDeleted', 'broadcast', 'broadcastCampaigns', id, {
|
emitDomainEvent('BroadcastCampaignDeleted', 'broadcasts', 'broadcastCampaigns', id, {
|
||||||
campaignId: id,
|
campaignId: id,
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -53,7 +53,7 @@ export const broadcastSettingsStore = {
|
||||||
});
|
});
|
||||||
emitDomainEvent(
|
emitDomainEvent(
|
||||||
'BroadcastSettingsUpdated',
|
'BroadcastSettingsUpdated',
|
||||||
'broadcast',
|
'broadcasts',
|
||||||
'broadcastSettings',
|
'broadcastSettings',
|
||||||
BROADCAST_SETTINGS_ID,
|
BROADCAST_SETTINGS_ID,
|
||||||
{ fields: Object.keys(patch) }
|
{ fields: Object.keys(patch) }
|
||||||
|
|
|
||||||
|
|
@ -60,7 +60,7 @@ async function listDecryptedCampaigns(): Promise<ReturnType<typeof toCampaign>[]
|
||||||
export const broadcastTools: ModuleTool[] = [
|
export const broadcastTools: ModuleTool[] = [
|
||||||
{
|
{
|
||||||
name: 'create_campaign_draft',
|
name: 'create_campaign_draft',
|
||||||
module: 'broadcast',
|
module: 'broadcasts',
|
||||||
description:
|
description:
|
||||||
'Erstellt einen Newsletter-/Kampagnen-Entwurf mit Name, Betreff, optionalem Preheader und fertigem HTML-Body.',
|
'Erstellt einen Newsletter-/Kampagnen-Entwurf mit Name, Betreff, optionalem Preheader und fertigem HTML-Body.',
|
||||||
parameters: [
|
parameters: [
|
||||||
|
|
@ -97,7 +97,7 @@ export const broadcastTools: ModuleTool[] = [
|
||||||
|
|
||||||
{
|
{
|
||||||
name: 'list_campaigns',
|
name: 'list_campaigns',
|
||||||
module: 'broadcast',
|
module: 'broadcasts',
|
||||||
description: 'Listet Kampagnen (id, name, subject, status, Empfängerzahl, sentAt).',
|
description: 'Listet Kampagnen (id, name, subject, status, Empfängerzahl, sentAt).',
|
||||||
parameters: [
|
parameters: [
|
||||||
{
|
{
|
||||||
|
|
@ -132,7 +132,7 @@ export const broadcastTools: ModuleTool[] = [
|
||||||
|
|
||||||
{
|
{
|
||||||
name: 'get_campaign_stats',
|
name: 'get_campaign_stats',
|
||||||
module: 'broadcast',
|
module: 'broadcasts',
|
||||||
description: 'Gibt Raten zu einer Kampagne zurück: Öffnungs-, Klick-, Bounce- und Abmelderate.',
|
description: 'Gibt Raten zu einer Kampagne zurück: Öffnungs-, Klick-, Bounce- und Abmelderate.',
|
||||||
parameters: [{ name: 'campaignId', type: 'string', description: 'ID', required: true }],
|
parameters: [{ name: 'campaignId', type: 'string', description: 'ID', required: true }],
|
||||||
async execute(params) {
|
async execute(params) {
|
||||||
|
|
|
||||||
|
|
@ -8,10 +8,10 @@
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { liveQuery } from 'dexie';
|
import { liveQuery } from 'dexie';
|
||||||
import { campaignTable } from '$lib/modules/broadcast/collections';
|
import { campaignTable } from '$lib/modules/broadcasts/collections';
|
||||||
import { decryptRecords } from '$lib/data/crypto';
|
import { decryptRecords } from '$lib/data/crypto';
|
||||||
import { toCampaign, computeStats, formatRate } from '$lib/modules/broadcast/queries';
|
import { toCampaign, computeStats, formatRate } from '$lib/modules/broadcasts/queries';
|
||||||
import type { Campaign, LocalCampaign } from '$lib/modules/broadcast/types';
|
import type { Campaign, LocalCampaign } from '$lib/modules/broadcasts/types';
|
||||||
|
|
||||||
let campaigns = $state<Campaign[]>([]);
|
let campaigns = $state<Campaign[]>([]);
|
||||||
let loading = $state(true);
|
let loading = $state(true);
|
||||||
|
|
|
||||||
506
apps/mana/apps/web/src/lib/modules/timeline/ListView.svelte
Normal file
506
apps/mana/apps/web/src/lib/modules/timeline/ListView.svelte
Normal file
|
|
@ -0,0 +1,506 @@
|
||||||
|
<!--
|
||||||
|
Timeline — Chronological day view of all timeBlocks.
|
||||||
|
"What did I do today?" as a standalone page.
|
||||||
|
-->
|
||||||
|
<script lang="ts">
|
||||||
|
import { getDateFnsLocale } from '$lib/i18n/format';
|
||||||
|
import { _ } from 'svelte-i18n';
|
||||||
|
import { useLiveQueryWithDefault } from '@mana/local-store/svelte';
|
||||||
|
import { db } from '$lib/data/database';
|
||||||
|
import type { LocalTimeBlock, TimeBlockType } from '$lib/data/time-blocks/types';
|
||||||
|
import { toTimeBlock, getBlockDuration } from '$lib/data/time-blocks/queries';
|
||||||
|
import type { TimeBlock } from '$lib/data/time-blocks/types';
|
||||||
|
import { getIconComponent } from '@mana/shared-icons';
|
||||||
|
import {
|
||||||
|
CaretLeft,
|
||||||
|
CaretRight,
|
||||||
|
CalendarBlank,
|
||||||
|
CheckSquare,
|
||||||
|
Timer,
|
||||||
|
Heart,
|
||||||
|
Lightning,
|
||||||
|
Clock,
|
||||||
|
Funnel,
|
||||||
|
} from '@mana/shared-icons';
|
||||||
|
import { format, addDays, subDays, isToday, isTomorrow, isYesterday } from 'date-fns';
|
||||||
|
|
||||||
|
let currentDate = $state(new Date());
|
||||||
|
let showFilters = $state(false);
|
||||||
|
let visibleTypes = $state<Set<TimeBlockType>>(
|
||||||
|
new Set(['event', 'task', 'habit', 'timeEntry', 'focus', 'break'])
|
||||||
|
);
|
||||||
|
|
||||||
|
let dateStr = $derived(format(currentDate, 'yyyy-MM-dd'));
|
||||||
|
let dayStart = $derived(`${dateStr}T00:00:00.000Z`);
|
||||||
|
let dayEnd = $derived(`${dateStr}T23:59:59.999Z`);
|
||||||
|
|
||||||
|
const blocksQuery = useLiveQueryWithDefault(async () => {
|
||||||
|
const locals = await db
|
||||||
|
.table<LocalTimeBlock>('timeBlocks')
|
||||||
|
.where('startDate')
|
||||||
|
.between(dayStart, dayEnd, true, true)
|
||||||
|
.toArray();
|
||||||
|
return locals
|
||||||
|
.filter((b) => !b.deletedAt)
|
||||||
|
.map(toTimeBlock)
|
||||||
|
.sort((a, b) => a.startDate.localeCompare(b.startDate));
|
||||||
|
}, [] as TimeBlock[]);
|
||||||
|
|
||||||
|
let allBlocks = $derived(blocksQuery.value ?? []);
|
||||||
|
let blocks = $derived(allBlocks.filter((b) => visibleTypes.has(b.type)));
|
||||||
|
|
||||||
|
// Stats
|
||||||
|
let totalSeconds = $derived(blocks.reduce((sum, b) => sum + getBlockDuration(b), 0));
|
||||||
|
let liveBlock = $derived(blocks.find((b) => b.isLive));
|
||||||
|
|
||||||
|
const typeConfig: {
|
||||||
|
type: TimeBlockType;
|
||||||
|
icon: typeof CalendarBlank;
|
||||||
|
label: string;
|
||||||
|
color: string;
|
||||||
|
}[] = [
|
||||||
|
{ type: 'event', icon: CalendarBlank, label: 'Termine', color: '#3b82f6' },
|
||||||
|
{ type: 'task', icon: CheckSquare, label: 'Aufgaben', color: '#f59e0b' },
|
||||||
|
{ type: 'timeEntry', icon: Timer, label: 'Zeiten', color: '#8b5cf6' },
|
||||||
|
{ type: 'habit', icon: Heart, label: 'Habits', color: '#22c55e' },
|
||||||
|
{ type: 'focus', icon: Lightning, label: 'Fokus', color: '#ef4444' },
|
||||||
|
];
|
||||||
|
|
||||||
|
function toggleType(type: TimeBlockType) {
|
||||||
|
const next = new Set(visibleTypes);
|
||||||
|
if (next.has(type)) next.delete(type);
|
||||||
|
else next.add(type);
|
||||||
|
visibleTypes = next;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatHeaderDate(date: Date): string {
|
||||||
|
if (isToday(date)) return 'Heute';
|
||||||
|
if (isTomorrow(date)) return 'Morgen';
|
||||||
|
if (isYesterday(date)) return 'Gestern';
|
||||||
|
return format(date, 'EEEE, d. MMMM yyyy', { locale: getDateFnsLocale() });
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatBlockTime(block: TimeBlock): string {
|
||||||
|
const start = format(new Date(block.startDate), 'HH:mm');
|
||||||
|
if (block.isLive) return `${start} — jetzt`;
|
||||||
|
if (!block.endDate) return start;
|
||||||
|
return `${start} — ${format(new Date(block.endDate), 'HH:mm')}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDuration(seconds: number): string {
|
||||||
|
if (seconds === 0) return '';
|
||||||
|
const h = Math.floor(seconds / 3600);
|
||||||
|
const m = Math.floor((seconds % 3600) / 60);
|
||||||
|
if (h === 0) return `${m}m`;
|
||||||
|
if (m === 0) return `${h}h`;
|
||||||
|
return `${h}h ${m}m`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getTypeColor(type: TimeBlockType): string {
|
||||||
|
return typeConfig.find((c) => c.type === type)?.color ?? '#6b7280';
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="timeline-page">
|
||||||
|
<header class="timeline-header">
|
||||||
|
<div class="header-left">
|
||||||
|
<h1 class="header-title">{formatHeaderDate(currentDate)}</h1>
|
||||||
|
<div class="nav-buttons">
|
||||||
|
<button onclick={() => (currentDate = subDays(currentDate, 1))} class="nav-btn">
|
||||||
|
<CaretLeft size={18} />
|
||||||
|
</button>
|
||||||
|
<button onclick={() => (currentDate = new Date())} class="today-btn">Heute</button>
|
||||||
|
<button onclick={() => (currentDate = addDays(currentDate, 1))} class="nav-btn">
|
||||||
|
<CaretRight size={18} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="header-right">
|
||||||
|
{#if totalSeconds > 0}
|
||||||
|
<span class="total-duration">{formatDuration(totalSeconds)} erfasst</span>
|
||||||
|
{/if}
|
||||||
|
<button
|
||||||
|
class="filter-btn"
|
||||||
|
class:active={visibleTypes.size < 6}
|
||||||
|
onclick={() => (showFilters = !showFilters)}
|
||||||
|
>
|
||||||
|
<Funnel size={16} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
{#if showFilters}
|
||||||
|
<div class="filter-bar">
|
||||||
|
{#each typeConfig as cfg}
|
||||||
|
{@const active = visibleTypes.has(cfg.type)}
|
||||||
|
{@const Icon = cfg.icon}
|
||||||
|
<button class="filter-chip" class:active onclick={() => toggleType(cfg.type)}>
|
||||||
|
<Icon size={14} />
|
||||||
|
{cfg.label}
|
||||||
|
</button>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<!-- Timeline content -->
|
||||||
|
<div class="timeline-content">
|
||||||
|
{#if blocks.length === 0}
|
||||||
|
<div class="empty">
|
||||||
|
<Clock size={48} class="empty-icon" />
|
||||||
|
<p>{isToday(currentDate) ? 'Noch nichts heute' : 'Keine Einträge an diesem Tag'}</p>
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<div class="timeline-list">
|
||||||
|
{#each blocks as block, i (block.id)}
|
||||||
|
{@const duration = getBlockDuration(block)}
|
||||||
|
{@const habitIcon =
|
||||||
|
block.type === 'habit' && block.icon ? getIconComponent(block.icon) : null}
|
||||||
|
{@const typeCfg = typeConfig.find((c) => c.type === block.type)}
|
||||||
|
|
||||||
|
<div class="timeline-item" class:live={block.isLive}>
|
||||||
|
<!-- Time column -->
|
||||||
|
<div class="time-col">
|
||||||
|
<span class="time-label">{format(new Date(block.startDate), 'HH:mm')}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Dot + line -->
|
||||||
|
<div class="dot-col">
|
||||||
|
<div
|
||||||
|
class="dot"
|
||||||
|
class:live={block.isLive}
|
||||||
|
style="background: {block.color || getTypeColor(block.type)}"
|
||||||
|
></div>
|
||||||
|
{#if i < blocks.length - 1}
|
||||||
|
<div class="connector-line"></div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Content -->
|
||||||
|
<div class="content-col">
|
||||||
|
<div class="item-header">
|
||||||
|
{#if habitIcon}
|
||||||
|
{@const HabitIcon = habitIcon}
|
||||||
|
<HabitIcon size={16} style="color: {block.color || '#6b7280'}" />
|
||||||
|
{:else if typeCfg}
|
||||||
|
{@const TypeIcon = typeCfg.icon}
|
||||||
|
<TypeIcon size={16} class="item-type-icon" />
|
||||||
|
{/if}
|
||||||
|
<span class="item-title">{block.title}</span>
|
||||||
|
{#if block.linkedBlockId}
|
||||||
|
<span class="linked-badge">erledigt</span>
|
||||||
|
{/if}
|
||||||
|
{#if block.isLive}
|
||||||
|
<span class="live-badge">live</span>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="item-meta">
|
||||||
|
<span>{formatBlockTime(block)}</span>
|
||||||
|
{#if duration > 0}
|
||||||
|
<span class="duration-pill">{formatDuration(duration)}</span>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if block.description}
|
||||||
|
<p class="item-description">{block.description}</p>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.timeline-page {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
height: 100%;
|
||||||
|
background: hsl(var(--color-background));
|
||||||
|
}
|
||||||
|
|
||||||
|
.timeline-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 1rem 1.5rem;
|
||||||
|
border-bottom: 1px solid hsl(var(--color-border));
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-left {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-title {
|
||||||
|
font-size: 1.25rem;
|
||||||
|
font-weight: 700;
|
||||||
|
color: hsl(var(--color-foreground));
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-buttons {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-btn {
|
||||||
|
padding: 0.375rem;
|
||||||
|
border: none;
|
||||||
|
background: transparent;
|
||||||
|
border-radius: var(--radius-md, 8px);
|
||||||
|
cursor: pointer;
|
||||||
|
color: hsl(var(--color-muted-foreground));
|
||||||
|
}
|
||||||
|
.nav-btn:hover {
|
||||||
|
background: hsl(var(--color-muted));
|
||||||
|
color: hsl(var(--color-foreground));
|
||||||
|
}
|
||||||
|
|
||||||
|
.today-btn {
|
||||||
|
padding: 0.25rem 0.75rem;
|
||||||
|
border: none;
|
||||||
|
background: transparent;
|
||||||
|
border-radius: var(--radius-md, 8px);
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
font-weight: 500;
|
||||||
|
color: hsl(var(--color-muted-foreground));
|
||||||
|
}
|
||||||
|
.today-btn:hover {
|
||||||
|
background: hsl(var(--color-muted));
|
||||||
|
color: hsl(var(--color-foreground));
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-right {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.total-duration {
|
||||||
|
font-size: 0.875rem;
|
||||||
|
font-weight: 500;
|
||||||
|
color: hsl(var(--color-muted-foreground));
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter-btn {
|
||||||
|
padding: 0.375rem;
|
||||||
|
border: 1px solid hsl(var(--color-border));
|
||||||
|
background: transparent;
|
||||||
|
border-radius: var(--radius-md, 8px);
|
||||||
|
cursor: pointer;
|
||||||
|
color: hsl(var(--color-muted-foreground));
|
||||||
|
}
|
||||||
|
.filter-btn:hover {
|
||||||
|
background: hsl(var(--color-muted));
|
||||||
|
}
|
||||||
|
.filter-btn.active {
|
||||||
|
background: hsl(var(--color-primary) / 0.1);
|
||||||
|
border-color: hsl(var(--color-primary) / 0.3);
|
||||||
|
color: hsl(var(--color-primary));
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter-bar {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.375rem;
|
||||||
|
padding: 0.75rem 1.5rem;
|
||||||
|
border-bottom: 1px solid hsl(var(--color-border));
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter-chip {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.25rem;
|
||||||
|
padding: 0.25rem 0.625rem;
|
||||||
|
border: 1px solid hsl(var(--color-border));
|
||||||
|
border-radius: 9999px;
|
||||||
|
background: transparent;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
font-weight: 500;
|
||||||
|
color: hsl(var(--color-muted-foreground));
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.filter-chip:hover {
|
||||||
|
background: hsl(var(--color-muted));
|
||||||
|
}
|
||||||
|
.filter-chip.active {
|
||||||
|
background: hsl(var(--color-primary) / 0.1);
|
||||||
|
border-color: hsl(var(--color-primary) / 0.3);
|
||||||
|
color: hsl(var(--color-primary));
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Timeline content */
|
||||||
|
.timeline-content {
|
||||||
|
flex: 1;
|
||||||
|
overflow-y: auto;
|
||||||
|
padding: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 1rem;
|
||||||
|
padding: 4rem 0;
|
||||||
|
color: hsl(var(--color-muted-foreground));
|
||||||
|
}
|
||||||
|
.empty p {
|
||||||
|
font-size: 0.875rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.timeline-list {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.timeline-item {
|
||||||
|
display: flex;
|
||||||
|
gap: 0;
|
||||||
|
min-height: 3.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.timeline-item.live {
|
||||||
|
background: hsl(var(--color-primary) / 0.03);
|
||||||
|
border-radius: var(--radius-md, 8px);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Time column */
|
||||||
|
.time-col {
|
||||||
|
width: 3.5rem;
|
||||||
|
flex-shrink: 0;
|
||||||
|
padding-top: 0.75rem;
|
||||||
|
text-align: right;
|
||||||
|
padding-right: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.time-label {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
font-weight: 500;
|
||||||
|
color: hsl(var(--color-muted-foreground));
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Dot + connector */
|
||||||
|
.dot-col {
|
||||||
|
width: 1.5rem;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dot {
|
||||||
|
width: 10px;
|
||||||
|
height: 10px;
|
||||||
|
border-radius: 50%;
|
||||||
|
margin-top: 0.85rem;
|
||||||
|
flex-shrink: 0;
|
||||||
|
z-index: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dot.live {
|
||||||
|
animation: pulse-dot 2s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes pulse-dot {
|
||||||
|
0%,
|
||||||
|
100% {
|
||||||
|
box-shadow: 0 0 0 0 currentColor;
|
||||||
|
}
|
||||||
|
50% {
|
||||||
|
box-shadow: 0 0 0 4px currentColor;
|
||||||
|
opacity: 0.3;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.connector-line {
|
||||||
|
width: 2px;
|
||||||
|
flex: 1;
|
||||||
|
background: hsl(var(--color-border));
|
||||||
|
margin-top: 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Content */
|
||||||
|
.content-col {
|
||||||
|
flex: 1;
|
||||||
|
padding: 0.5rem 0 1rem 0.5rem;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.item-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.item-title {
|
||||||
|
font-size: 0.9375rem;
|
||||||
|
font-weight: 500;
|
||||||
|
color: hsl(var(--color-foreground));
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
.linked-badge {
|
||||||
|
font-size: 0.625rem;
|
||||||
|
font-weight: 600;
|
||||||
|
padding: 0.0625rem 0.375rem;
|
||||||
|
border-radius: 9999px;
|
||||||
|
background: hsl(var(--color-success, 142 71% 45%) / 0.15);
|
||||||
|
color: hsl(var(--color-success, 142 71% 45%));
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.live-badge {
|
||||||
|
font-size: 0.625rem;
|
||||||
|
font-weight: 600;
|
||||||
|
padding: 0.0625rem 0.375rem;
|
||||||
|
border-radius: 9999px;
|
||||||
|
background: hsl(var(--color-primary) / 0.15);
|
||||||
|
color: hsl(var(--color-primary));
|
||||||
|
animation: pulse-badge 2s ease-in-out infinite;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes pulse-badge {
|
||||||
|
0%,
|
||||||
|
100% {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
50% {
|
||||||
|
opacity: 0.6;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.item-meta {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
color: hsl(var(--color-muted-foreground));
|
||||||
|
margin-top: 0.125rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.duration-pill {
|
||||||
|
padding: 0 0.375rem;
|
||||||
|
border-radius: 9999px;
|
||||||
|
background: hsl(var(--color-muted));
|
||||||
|
font-size: 0.6875rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.item-description {
|
||||||
|
margin: 0.25rem 0 0;
|
||||||
|
font-size: 0.8125rem;
|
||||||
|
color: hsl(var(--color-muted-foreground));
|
||||||
|
line-height: 1.4;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
12
apps/mana/apps/web/src/routes/(app)/activity/+page.svelte
Normal file
12
apps/mana/apps/web/src/routes/(app)/activity/+page.svelte
Normal file
|
|
@ -0,0 +1,12 @@
|
||||||
|
<script lang="ts">
|
||||||
|
import ListView from '$lib/modules/activity/ListView.svelte';
|
||||||
|
import { RoutePage } from '$lib/components/shell';
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<svelte:head>
|
||||||
|
<title>Aktivität - Mana</title>
|
||||||
|
</svelte:head>
|
||||||
|
|
||||||
|
<RoutePage appId="activity">
|
||||||
|
<ListView />
|
||||||
|
</RoutePage>
|
||||||
12
apps/mana/apps/web/src/routes/(app)/automations/+page.svelte
Normal file
12
apps/mana/apps/web/src/routes/(app)/automations/+page.svelte
Normal file
|
|
@ -0,0 +1,12 @@
|
||||||
|
<script lang="ts">
|
||||||
|
import ListView from '$lib/modules/automations/ListView.svelte';
|
||||||
|
import { RoutePage } from '$lib/components/shell';
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<svelte:head>
|
||||||
|
<title>Automations - Mana</title>
|
||||||
|
</svelte:head>
|
||||||
|
|
||||||
|
<RoutePage appId="automations">
|
||||||
|
<ListView navigate={() => {}} goBack={() => history.back()} params={{}} />
|
||||||
|
</RoutePage>
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import ListView from '$lib/modules/broadcast/ListView.svelte';
|
import ListView from '$lib/modules/broadcasts/ListView.svelte';
|
||||||
import { RoutePage } from '$lib/components/shell';
|
import { RoutePage } from '$lib/components/shell';
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,8 @@
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { page } from '$app/stores';
|
import { page } from '$app/stores';
|
||||||
import { goto } from '$app/navigation';
|
import { goto } from '$app/navigation';
|
||||||
import { useAllCampaigns } from '$lib/modules/broadcast/queries';
|
import { useAllCampaigns } from '$lib/modules/broadcasts/queries';
|
||||||
import DetailView from '$lib/modules/broadcast/views/DetailView.svelte';
|
import DetailView from '$lib/modules/broadcasts/views/DetailView.svelte';
|
||||||
import { RoutePage } from '$lib/components/shell';
|
import { RoutePage } from '$lib/components/shell';
|
||||||
|
|
||||||
const campaigns$ = useAllCampaigns();
|
const campaigns$ = useAllCampaigns();
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { page } from '$app/stores';
|
import { page } from '$app/stores';
|
||||||
import { useAllCampaigns } from '$lib/modules/broadcast/queries';
|
import { useAllCampaigns } from '$lib/modules/broadcasts/queries';
|
||||||
import ComposeView from '$lib/modules/broadcast/views/ComposeView.svelte';
|
import ComposeView from '$lib/modules/broadcasts/views/ComposeView.svelte';
|
||||||
import { RoutePage } from '$lib/components/shell';
|
import { RoutePage } from '$lib/components/shell';
|
||||||
|
|
||||||
const campaigns$ = useAllCampaigns();
|
const campaigns$ = useAllCampaigns();
|
||||||
|
|
|
||||||
|
|
@ -6,7 +6,7 @@
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { goto } from '$app/navigation';
|
import { goto } from '$app/navigation';
|
||||||
import { onMount } from 'svelte';
|
import { onMount } from 'svelte';
|
||||||
import { broadcastCampaignsStore } from '$lib/modules/broadcast/stores/campaigns.svelte';
|
import { broadcastCampaignsStore } from '$lib/modules/broadcasts/stores/campaigns.svelte';
|
||||||
import { RoutePage } from '$lib/components/shell';
|
import { RoutePage } from '$lib/components/shell';
|
||||||
|
|
||||||
let error = $state<string | null>(null);
|
let error = $state<string | null>(null);
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,7 @@
|
||||||
Reached via the ⚙ button in the broadcast module; not a workbench card.
|
Reached via the ⚙ button in the broadcast module; not a workbench card.
|
||||||
-->
|
-->
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import SettingsForm from '$lib/modules/broadcast/components/SettingsForm.svelte';
|
import SettingsForm from '$lib/modules/broadcasts/components/SettingsForm.svelte';
|
||||||
import { RoutePage } from '$lib/components/shell';
|
import { RoutePage } from '$lib/components/shell';
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|
|
||||||
12
apps/mana/apps/web/src/routes/(app)/goals/+page.svelte
Normal file
12
apps/mana/apps/web/src/routes/(app)/goals/+page.svelte
Normal file
|
|
@ -0,0 +1,12 @@
|
||||||
|
<script lang="ts">
|
||||||
|
import ListView from '$lib/modules/goals/ListView.svelte';
|
||||||
|
import { RoutePage } from '$lib/components/shell';
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<svelte:head>
|
||||||
|
<title>Ziele - Mana</title>
|
||||||
|
</svelte:head>
|
||||||
|
|
||||||
|
<RoutePage appId="goals">
|
||||||
|
<ListView />
|
||||||
|
</RoutePage>
|
||||||
12
apps/mana/apps/web/src/routes/(app)/kontext/+page.svelte
Normal file
12
apps/mana/apps/web/src/routes/(app)/kontext/+page.svelte
Normal file
|
|
@ -0,0 +1,12 @@
|
||||||
|
<script lang="ts">
|
||||||
|
import KontextView from '$lib/modules/kontext/KontextView.svelte';
|
||||||
|
import { RoutePage } from '$lib/components/shell';
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<svelte:head>
|
||||||
|
<title>Web-Context - Mana</title>
|
||||||
|
</svelte:head>
|
||||||
|
|
||||||
|
<RoutePage appId="kontext">
|
||||||
|
<KontextView />
|
||||||
|
</RoutePage>
|
||||||
12
apps/mana/apps/web/src/routes/(app)/myday/+page.svelte
Normal file
12
apps/mana/apps/web/src/routes/(app)/myday/+page.svelte
Normal file
|
|
@ -0,0 +1,12 @@
|
||||||
|
<script lang="ts">
|
||||||
|
import ListView from '$lib/modules/myday/ListView.svelte';
|
||||||
|
import { RoutePage } from '$lib/components/shell';
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<svelte:head>
|
||||||
|
<title>Mein Tag - Mana</title>
|
||||||
|
</svelte:head>
|
||||||
|
|
||||||
|
<RoutePage appId="myday">
|
||||||
|
<ListView />
|
||||||
|
</RoutePage>
|
||||||
12
apps/mana/apps/web/src/routes/(app)/rituals/+page.svelte
Normal file
12
apps/mana/apps/web/src/routes/(app)/rituals/+page.svelte
Normal file
|
|
@ -0,0 +1,12 @@
|
||||||
|
<script lang="ts">
|
||||||
|
import ListView from '$lib/modules/rituals/ListView.svelte';
|
||||||
|
import { RoutePage } from '$lib/components/shell';
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<svelte:head>
|
||||||
|
<title>Rituale - Mana</title>
|
||||||
|
</svelte:head>
|
||||||
|
|
||||||
|
<RoutePage appId="rituals">
|
||||||
|
<ListView />
|
||||||
|
</RoutePage>
|
||||||
|
|
@ -1,510 +1,12 @@
|
||||||
<!--
|
|
||||||
Timeline — Chronological day view of all timeBlocks.
|
|
||||||
"What did I do today?" as a standalone page.
|
|
||||||
-->
|
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { getDateFnsLocale } from '$lib/i18n/format';
|
import ListView from '$lib/modules/timeline/ListView.svelte';
|
||||||
import { _ } from 'svelte-i18n';
|
|
||||||
import { useLiveQueryWithDefault } from '@mana/local-store/svelte';
|
|
||||||
import { db } from '$lib/data/database';
|
|
||||||
import type { LocalTimeBlock, TimeBlockType } from '$lib/data/time-blocks/types';
|
|
||||||
import { toTimeBlock, getBlockDuration } from '$lib/data/time-blocks/queries';
|
|
||||||
import type { TimeBlock } from '$lib/data/time-blocks/types';
|
|
||||||
import { getIconComponent } from '@mana/shared-icons';
|
|
||||||
import {
|
|
||||||
CaretLeft,
|
|
||||||
CaretRight,
|
|
||||||
CalendarBlank,
|
|
||||||
CheckSquare,
|
|
||||||
Timer,
|
|
||||||
Heart,
|
|
||||||
Lightning,
|
|
||||||
Clock,
|
|
||||||
Funnel,
|
|
||||||
} from '@mana/shared-icons';
|
|
||||||
import { format, addDays, subDays, isToday, isTomorrow, isYesterday } from 'date-fns';
|
|
||||||
import { RoutePage } from '$lib/components/shell';
|
import { RoutePage } from '$lib/components/shell';
|
||||||
|
|
||||||
let currentDate = $state(new Date());
|
|
||||||
let showFilters = $state(false);
|
|
||||||
let visibleTypes = $state<Set<TimeBlockType>>(
|
|
||||||
new Set(['event', 'task', 'habit', 'timeEntry', 'focus', 'break'])
|
|
||||||
);
|
|
||||||
|
|
||||||
let dateStr = $derived(format(currentDate, 'yyyy-MM-dd'));
|
|
||||||
let dayStart = $derived(`${dateStr}T00:00:00.000Z`);
|
|
||||||
let dayEnd = $derived(`${dateStr}T23:59:59.999Z`);
|
|
||||||
|
|
||||||
const blocksQuery = useLiveQueryWithDefault(async () => {
|
|
||||||
const locals = await db
|
|
||||||
.table<LocalTimeBlock>('timeBlocks')
|
|
||||||
.where('startDate')
|
|
||||||
.between(dayStart, dayEnd, true, true)
|
|
||||||
.toArray();
|
|
||||||
return locals
|
|
||||||
.filter((b) => !b.deletedAt)
|
|
||||||
.map(toTimeBlock)
|
|
||||||
.sort((a, b) => a.startDate.localeCompare(b.startDate));
|
|
||||||
}, [] as TimeBlock[]);
|
|
||||||
|
|
||||||
let allBlocks = $derived(blocksQuery.value ?? []);
|
|
||||||
let blocks = $derived(allBlocks.filter((b) => visibleTypes.has(b.type)));
|
|
||||||
|
|
||||||
// Stats
|
|
||||||
let totalSeconds = $derived(blocks.reduce((sum, b) => sum + getBlockDuration(b), 0));
|
|
||||||
let liveBlock = $derived(blocks.find((b) => b.isLive));
|
|
||||||
|
|
||||||
const typeConfig: {
|
|
||||||
type: TimeBlockType;
|
|
||||||
icon: typeof CalendarBlank;
|
|
||||||
label: string;
|
|
||||||
color: string;
|
|
||||||
}[] = [
|
|
||||||
{ type: 'event', icon: CalendarBlank, label: 'Termine', color: '#3b82f6' },
|
|
||||||
{ type: 'task', icon: CheckSquare, label: 'Aufgaben', color: '#f59e0b' },
|
|
||||||
{ type: 'timeEntry', icon: Timer, label: 'Zeiten', color: '#8b5cf6' },
|
|
||||||
{ type: 'habit', icon: Heart, label: 'Habits', color: '#22c55e' },
|
|
||||||
{ type: 'focus', icon: Lightning, label: 'Fokus', color: '#ef4444' },
|
|
||||||
];
|
|
||||||
|
|
||||||
function toggleType(type: TimeBlockType) {
|
|
||||||
const next = new Set(visibleTypes);
|
|
||||||
if (next.has(type)) next.delete(type);
|
|
||||||
else next.add(type);
|
|
||||||
visibleTypes = next;
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatHeaderDate(date: Date): string {
|
|
||||||
if (isToday(date)) return 'Heute';
|
|
||||||
if (isTomorrow(date)) return 'Morgen';
|
|
||||||
if (isYesterday(date)) return 'Gestern';
|
|
||||||
return format(date, 'EEEE, d. MMMM yyyy', { locale: getDateFnsLocale() });
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatBlockTime(block: TimeBlock): string {
|
|
||||||
const start = format(new Date(block.startDate), 'HH:mm');
|
|
||||||
if (block.isLive) return `${start} — jetzt`;
|
|
||||||
if (!block.endDate) return start;
|
|
||||||
return `${start} — ${format(new Date(block.endDate), 'HH:mm')}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatDuration(seconds: number): string {
|
|
||||||
if (seconds === 0) return '';
|
|
||||||
const h = Math.floor(seconds / 3600);
|
|
||||||
const m = Math.floor((seconds % 3600) / 60);
|
|
||||||
if (h === 0) return `${m}m`;
|
|
||||||
if (m === 0) return `${h}h`;
|
|
||||||
return `${h}h ${m}m`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function getTypeColor(type: TimeBlockType): string {
|
|
||||||
return typeConfig.find((c) => c.type === type)?.color ?? '#6b7280';
|
|
||||||
}
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
<svelte:head>
|
||||||
|
<title>Timeline - Mana</title>
|
||||||
|
</svelte:head>
|
||||||
|
|
||||||
<RoutePage appId="timeline">
|
<RoutePage appId="timeline">
|
||||||
<div class="timeline-page">
|
<ListView />
|
||||||
<!-- Header -->
|
|
||||||
<header class="timeline-header">
|
|
||||||
<div class="header-left">
|
|
||||||
<h1 class="header-title">{formatHeaderDate(currentDate)}</h1>
|
|
||||||
<div class="nav-buttons">
|
|
||||||
<button onclick={() => (currentDate = subDays(currentDate, 1))} class="nav-btn">
|
|
||||||
<CaretLeft size={18} />
|
|
||||||
</button>
|
|
||||||
<button onclick={() => (currentDate = new Date())} class="today-btn">Heute</button>
|
|
||||||
<button onclick={() => (currentDate = addDays(currentDate, 1))} class="nav-btn">
|
|
||||||
<CaretRight size={18} />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="header-right">
|
|
||||||
{#if totalSeconds > 0}
|
|
||||||
<span class="total-duration">{formatDuration(totalSeconds)} erfasst</span>
|
|
||||||
{/if}
|
|
||||||
<button
|
|
||||||
class="filter-btn"
|
|
||||||
class:active={visibleTypes.size < 6}
|
|
||||||
onclick={() => (showFilters = !showFilters)}
|
|
||||||
>
|
|
||||||
<Funnel size={16} />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</header>
|
|
||||||
|
|
||||||
{#if showFilters}
|
|
||||||
<div class="filter-bar">
|
|
||||||
{#each typeConfig as cfg}
|
|
||||||
{@const active = visibleTypes.has(cfg.type)}
|
|
||||||
{@const Icon = cfg.icon}
|
|
||||||
<button class="filter-chip" class:active onclick={() => toggleType(cfg.type)}>
|
|
||||||
<Icon size={14} />
|
|
||||||
{cfg.label}
|
|
||||||
</button>
|
|
||||||
{/each}
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
<!-- Timeline content -->
|
|
||||||
<div class="timeline-content">
|
|
||||||
{#if blocks.length === 0}
|
|
||||||
<div class="empty">
|
|
||||||
<Clock size={48} class="empty-icon" />
|
|
||||||
<p>{isToday(currentDate) ? 'Noch nichts heute' : 'Keine Einträge an diesem Tag'}</p>
|
|
||||||
</div>
|
|
||||||
{:else}
|
|
||||||
<div class="timeline-list">
|
|
||||||
{#each blocks as block, i (block.id)}
|
|
||||||
{@const duration = getBlockDuration(block)}
|
|
||||||
{@const habitIcon =
|
|
||||||
block.type === 'habit' && block.icon ? getIconComponent(block.icon) : null}
|
|
||||||
{@const typeCfg = typeConfig.find((c) => c.type === block.type)}
|
|
||||||
|
|
||||||
<div class="timeline-item" class:live={block.isLive}>
|
|
||||||
<!-- Time column -->
|
|
||||||
<div class="time-col">
|
|
||||||
<span class="time-label">{format(new Date(block.startDate), 'HH:mm')}</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Dot + line -->
|
|
||||||
<div class="dot-col">
|
|
||||||
<div
|
|
||||||
class="dot"
|
|
||||||
class:live={block.isLive}
|
|
||||||
style="background: {block.color || getTypeColor(block.type)}"
|
|
||||||
></div>
|
|
||||||
{#if i < blocks.length - 1}
|
|
||||||
<div class="connector-line"></div>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Content -->
|
|
||||||
<div class="content-col">
|
|
||||||
<div class="item-header">
|
|
||||||
{#if habitIcon}
|
|
||||||
{@const HabitIcon = habitIcon}
|
|
||||||
<HabitIcon size={16} style="color: {block.color || '#6b7280'}" />
|
|
||||||
{:else if typeCfg}
|
|
||||||
{@const TypeIcon = typeCfg.icon}
|
|
||||||
<TypeIcon size={16} class="item-type-icon" />
|
|
||||||
{/if}
|
|
||||||
<span class="item-title">{block.title}</span>
|
|
||||||
{#if block.linkedBlockId}
|
|
||||||
<span class="linked-badge">erledigt</span>
|
|
||||||
{/if}
|
|
||||||
{#if block.isLive}
|
|
||||||
<span class="live-badge">live</span>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="item-meta">
|
|
||||||
<span>{formatBlockTime(block)}</span>
|
|
||||||
{#if duration > 0}
|
|
||||||
<span class="duration-pill">{formatDuration(duration)}</span>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{#if block.description}
|
|
||||||
<p class="item-description">{block.description}</p>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{/each}
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</RoutePage>
|
</RoutePage>
|
||||||
|
|
||||||
<style>
|
|
||||||
.timeline-page {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
height: 100%;
|
|
||||||
background: hsl(var(--color-background));
|
|
||||||
}
|
|
||||||
|
|
||||||
.timeline-header {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: space-between;
|
|
||||||
padding: 1rem 1.5rem;
|
|
||||||
border-bottom: 1px solid hsl(var(--color-border));
|
|
||||||
flex-wrap: wrap;
|
|
||||||
gap: 0.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.header-left {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 1rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.header-title {
|
|
||||||
font-size: 1.25rem;
|
|
||||||
font-weight: 700;
|
|
||||||
color: hsl(var(--color-foreground));
|
|
||||||
margin: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.nav-buttons {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 0.25rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.nav-btn {
|
|
||||||
padding: 0.375rem;
|
|
||||||
border: none;
|
|
||||||
background: transparent;
|
|
||||||
border-radius: var(--radius-md, 8px);
|
|
||||||
cursor: pointer;
|
|
||||||
color: hsl(var(--color-muted-foreground));
|
|
||||||
}
|
|
||||||
.nav-btn:hover {
|
|
||||||
background: hsl(var(--color-muted));
|
|
||||||
color: hsl(var(--color-foreground));
|
|
||||||
}
|
|
||||||
|
|
||||||
.today-btn {
|
|
||||||
padding: 0.25rem 0.75rem;
|
|
||||||
border: none;
|
|
||||||
background: transparent;
|
|
||||||
border-radius: var(--radius-md, 8px);
|
|
||||||
cursor: pointer;
|
|
||||||
font-size: 0.875rem;
|
|
||||||
font-weight: 500;
|
|
||||||
color: hsl(var(--color-muted-foreground));
|
|
||||||
}
|
|
||||||
.today-btn:hover {
|
|
||||||
background: hsl(var(--color-muted));
|
|
||||||
color: hsl(var(--color-foreground));
|
|
||||||
}
|
|
||||||
|
|
||||||
.header-right {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 0.75rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.total-duration {
|
|
||||||
font-size: 0.875rem;
|
|
||||||
font-weight: 500;
|
|
||||||
color: hsl(var(--color-muted-foreground));
|
|
||||||
}
|
|
||||||
|
|
||||||
.filter-btn {
|
|
||||||
padding: 0.375rem;
|
|
||||||
border: 1px solid hsl(var(--color-border));
|
|
||||||
background: transparent;
|
|
||||||
border-radius: var(--radius-md, 8px);
|
|
||||||
cursor: pointer;
|
|
||||||
color: hsl(var(--color-muted-foreground));
|
|
||||||
}
|
|
||||||
.filter-btn:hover {
|
|
||||||
background: hsl(var(--color-muted));
|
|
||||||
}
|
|
||||||
.filter-btn.active {
|
|
||||||
background: hsl(var(--color-primary) / 0.1);
|
|
||||||
border-color: hsl(var(--color-primary) / 0.3);
|
|
||||||
color: hsl(var(--color-primary));
|
|
||||||
}
|
|
||||||
|
|
||||||
.filter-bar {
|
|
||||||
display: flex;
|
|
||||||
gap: 0.375rem;
|
|
||||||
padding: 0.75rem 1.5rem;
|
|
||||||
border-bottom: 1px solid hsl(var(--color-border));
|
|
||||||
}
|
|
||||||
|
|
||||||
.filter-chip {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 0.25rem;
|
|
||||||
padding: 0.25rem 0.625rem;
|
|
||||||
border: 1px solid hsl(var(--color-border));
|
|
||||||
border-radius: 9999px;
|
|
||||||
background: transparent;
|
|
||||||
font-size: 0.75rem;
|
|
||||||
font-weight: 500;
|
|
||||||
color: hsl(var(--color-muted-foreground));
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
|
||||||
.filter-chip:hover {
|
|
||||||
background: hsl(var(--color-muted));
|
|
||||||
}
|
|
||||||
.filter-chip.active {
|
|
||||||
background: hsl(var(--color-primary) / 0.1);
|
|
||||||
border-color: hsl(var(--color-primary) / 0.3);
|
|
||||||
color: hsl(var(--color-primary));
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Timeline content */
|
|
||||||
.timeline-content {
|
|
||||||
flex: 1;
|
|
||||||
overflow-y: auto;
|
|
||||||
padding: 1.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.empty {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
gap: 1rem;
|
|
||||||
padding: 4rem 0;
|
|
||||||
color: hsl(var(--color-muted-foreground));
|
|
||||||
}
|
|
||||||
.empty p {
|
|
||||||
font-size: 0.875rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.timeline-list {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
}
|
|
||||||
|
|
||||||
.timeline-item {
|
|
||||||
display: flex;
|
|
||||||
gap: 0;
|
|
||||||
min-height: 3.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.timeline-item.live {
|
|
||||||
background: hsl(var(--color-primary) / 0.03);
|
|
||||||
border-radius: var(--radius-md, 8px);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Time column */
|
|
||||||
.time-col {
|
|
||||||
width: 3.5rem;
|
|
||||||
flex-shrink: 0;
|
|
||||||
padding-top: 0.75rem;
|
|
||||||
text-align: right;
|
|
||||||
padding-right: 0.75rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.time-label {
|
|
||||||
font-size: 0.75rem;
|
|
||||||
font-weight: 500;
|
|
||||||
color: hsl(var(--color-muted-foreground));
|
|
||||||
font-variant-numeric: tabular-nums;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Dot + connector */
|
|
||||||
.dot-col {
|
|
||||||
width: 1.5rem;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
align-items: center;
|
|
||||||
flex-shrink: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.dot {
|
|
||||||
width: 10px;
|
|
||||||
height: 10px;
|
|
||||||
border-radius: 50%;
|
|
||||||
margin-top: 0.85rem;
|
|
||||||
flex-shrink: 0;
|
|
||||||
z-index: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
.dot.live {
|
|
||||||
animation: pulse-dot 2s ease-in-out infinite;
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes pulse-dot {
|
|
||||||
0%,
|
|
||||||
100% {
|
|
||||||
box-shadow: 0 0 0 0 currentColor;
|
|
||||||
}
|
|
||||||
50% {
|
|
||||||
box-shadow: 0 0 0 4px currentColor;
|
|
||||||
opacity: 0.3;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.connector-line {
|
|
||||||
width: 2px;
|
|
||||||
flex: 1;
|
|
||||||
background: hsl(var(--color-border));
|
|
||||||
margin-top: 0.25rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Content */
|
|
||||||
.content-col {
|
|
||||||
flex: 1;
|
|
||||||
padding: 0.5rem 0 1rem 0.5rem;
|
|
||||||
min-width: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.item-header {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 0.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.item-title {
|
|
||||||
font-size: 0.9375rem;
|
|
||||||
font-weight: 500;
|
|
||||||
color: hsl(var(--color-foreground));
|
|
||||||
white-space: nowrap;
|
|
||||||
overflow: hidden;
|
|
||||||
text-overflow: ellipsis;
|
|
||||||
}
|
|
||||||
|
|
||||||
.linked-badge {
|
|
||||||
font-size: 0.625rem;
|
|
||||||
font-weight: 600;
|
|
||||||
padding: 0.0625rem 0.375rem;
|
|
||||||
border-radius: 9999px;
|
|
||||||
background: hsl(var(--color-success, 142 71% 45%) / 0.15);
|
|
||||||
color: hsl(var(--color-success, 142 71% 45%));
|
|
||||||
flex-shrink: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.live-badge {
|
|
||||||
font-size: 0.625rem;
|
|
||||||
font-weight: 600;
|
|
||||||
padding: 0.0625rem 0.375rem;
|
|
||||||
border-radius: 9999px;
|
|
||||||
background: hsl(var(--color-primary) / 0.15);
|
|
||||||
color: hsl(var(--color-primary));
|
|
||||||
animation: pulse-badge 2s ease-in-out infinite;
|
|
||||||
flex-shrink: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes pulse-badge {
|
|
||||||
0%,
|
|
||||||
100% {
|
|
||||||
opacity: 1;
|
|
||||||
}
|
|
||||||
50% {
|
|
||||||
opacity: 0.6;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.item-meta {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 0.5rem;
|
|
||||||
font-size: 0.75rem;
|
|
||||||
color: hsl(var(--color-muted-foreground));
|
|
||||||
margin-top: 0.125rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.duration-pill {
|
|
||||||
padding: 0 0.375rem;
|
|
||||||
border-radius: 9999px;
|
|
||||||
background: hsl(var(--color-muted));
|
|
||||||
font-size: 0.6875rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.item-description {
|
|
||||||
margin: 0.25rem 0 0;
|
|
||||||
font-size: 0.8125rem;
|
|
||||||
color: hsl(var(--color-muted-foreground));
|
|
||||||
line-height: 1.4;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
|
|
|
||||||
|
|
@ -28,7 +28,8 @@
|
||||||
"validate:i18n-hardcoded": "node scripts/validate-no-hardcoded-strings.mjs",
|
"validate:i18n-hardcoded": "node scripts/validate-no-hardcoded-strings.mjs",
|
||||||
"validate:i18n-keys": "node scripts/validate-i18n-keys.mjs",
|
"validate:i18n-keys": "node scripts/validate-i18n-keys.mjs",
|
||||||
"validate:llm-strings": "node scripts/validate-llm-strings.mjs",
|
"validate:llm-strings": "node scripts/validate-llm-strings.mjs",
|
||||||
"validate:all": "pnpm run validate:turbo && pnpm run validate:pg-schema && pnpm run validate:theme-variables && pnpm run validate:theme-utilities && pnpm run validate:theme-parity && pnpm run validate:i18n-parity && pnpm run validate:i18n-hardcoded && pnpm run validate:i18n-keys && pnpm run validate:llm-strings && pnpm run check:crypto && pnpm run audit:encrypted-tools",
|
"validate:tier-patches": "node scripts/validate-tier-patches.mjs",
|
||||||
|
"validate:all": "pnpm run validate:turbo && pnpm run validate:pg-schema && pnpm run validate:theme-variables && pnpm run validate:theme-utilities && pnpm run validate:theme-parity && pnpm run validate:i18n-parity && pnpm run validate:i18n-hardcoded && pnpm run validate:i18n-keys && pnpm run validate:llm-strings && pnpm run validate:tier-patches && pnpm run check:crypto && pnpm run audit:encrypted-tools",
|
||||||
"check:crypto": "node scripts/audit-crypto-registry.mjs",
|
"check:crypto": "node scripts/audit-crypto-registry.mjs",
|
||||||
"check:crypto:seed": "node scripts/audit-crypto-registry.mjs --seed",
|
"check:crypto:seed": "node scripts/audit-crypto-registry.mjs --seed",
|
||||||
"audit:encrypted-tools": "bun run scripts/audit-encrypted-tools.ts",
|
"audit:encrypted-tools": "bun run scripts/audit-encrypted-tools.ts",
|
||||||
|
|
|
||||||
|
|
@ -276,7 +276,7 @@ export const APP_ICONS = {
|
||||||
// Emerald→teal sits next to finance green in the Arbeit & Finanzen row.
|
// Emerald→teal sits next to finance green in the Arbeit & Finanzen row.
|
||||||
`<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100"><defs><linearGradient id="iv" x1="0%" y1="0%" x2="100%" y2="100%"><stop offset="0%" style="stop-color:#059669"/><stop offset="100%" style="stop-color:#14b8a6"/></linearGradient></defs><rect width="100" height="100" rx="22" fill="url(#iv)"/><path d="M28 22h34l14 14v42a4 4 0 0 1-4 4H28a4 4 0 0 1-4-4V26a4 4 0 0 1 4-4z" fill="white" fill-opacity="0.95"/><path d="M62 22v10a4 4 0 0 0 4 4h10" fill="none" stroke="#059669" stroke-width="2" stroke-opacity="0.35"/><rect x="32" y="44" width="24" height="3" rx="1" fill="#059669" fill-opacity="0.6"/><rect x="32" y="52" width="20" height="3" rx="1" fill="#059669" fill-opacity="0.45"/><rect x="32" y="60" width="28" height="3" rx="1" fill="#059669" fill-opacity="0.6"/><rect x="60" y="58" width="14" height="14" rx="1" fill="#059669"/><rect x="62" y="60" width="3" height="3" fill="white"/><rect x="69" y="60" width="3" height="3" fill="white"/><rect x="62" y="67" width="3" height="3" fill="white"/><rect x="66" y="64" width="2" height="2" fill="white"/><rect x="69" y="67" width="3" height="3" fill="white"/></svg>`
|
`<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100"><defs><linearGradient id="iv" x1="0%" y1="0%" x2="100%" y2="100%"><stop offset="0%" style="stop-color:#059669"/><stop offset="100%" style="stop-color:#14b8a6"/></linearGradient></defs><rect width="100" height="100" rx="22" fill="url(#iv)"/><path d="M28 22h34l14 14v42a4 4 0 0 1-4 4H28a4 4 0 0 1-4-4V26a4 4 0 0 1 4-4z" fill="white" fill-opacity="0.95"/><path d="M62 22v10a4 4 0 0 0 4 4h10" fill="none" stroke="#059669" stroke-width="2" stroke-opacity="0.35"/><rect x="32" y="44" width="24" height="3" rx="1" fill="#059669" fill-opacity="0.6"/><rect x="32" y="52" width="20" height="3" rx="1" fill="#059669" fill-opacity="0.45"/><rect x="32" y="60" width="28" height="3" rx="1" fill="#059669" fill-opacity="0.6"/><rect x="60" y="58" width="14" height="14" rx="1" fill="#059669"/><rect x="62" y="60" width="3" height="3" fill="white"/><rect x="69" y="60" width="3" height="3" fill="white"/><rect x="62" y="67" width="3" height="3" fill="white"/><rect x="66" y="64" width="2" height="2" fill="white"/><rect x="69" y="67" width="3" height="3" fill="white"/></svg>`
|
||||||
),
|
),
|
||||||
broadcast: svgToDataUrl(
|
broadcasts: svgToDataUrl(
|
||||||
// Megaphone / loudspeaker with three radiating sound arcs.
|
// Megaphone / loudspeaker with three radiating sound arcs.
|
||||||
// Indigo→cyan gradient sets it apart from mail (blue) and invoices
|
// Indigo→cyan gradient sets it apart from mail (blue) and invoices
|
||||||
// (emerald) while staying in the "communication" colour family.
|
// (emerald) while staying in the "communication" colour family.
|
||||||
|
|
|
||||||
|
|
@ -255,7 +255,6 @@ export const MANA_APPS: ManaApp[] = [
|
||||||
comingSoon: false,
|
comingSoon: false,
|
||||||
status: 'development',
|
status: 'development',
|
||||||
requiredTier: 'guest',
|
requiredTier: 'guest',
|
||||||
archived: true,
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'contacts',
|
id: 'contacts',
|
||||||
|
|
@ -1106,7 +1105,7 @@ export const MANA_APPS: ManaApp[] = [
|
||||||
requiredTier: 'guest', // LOCAL TIER PATCH — revert to 'beta' before release
|
requiredTier: 'guest', // LOCAL TIER PATCH — revert to 'beta' before release
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'broadcast',
|
id: 'broadcasts',
|
||||||
name: 'Broadcasts',
|
name: 'Broadcasts',
|
||||||
description: {
|
description: {
|
||||||
de: 'Newsletter & Kampagnen',
|
de: 'Newsletter & Kampagnen',
|
||||||
|
|
@ -1116,7 +1115,7 @@ export const MANA_APPS: ManaApp[] = [
|
||||||
de: 'Newsletter und Ankündigungen an Kontaktgruppen versenden — mit Rich-Text-Editor, Open/Click-Tracking, DSGVO-konformem Unsubscribe und Kampagnen-Statistik.',
|
de: 'Newsletter und Ankündigungen an Kontaktgruppen versenden — mit Rich-Text-Editor, Open/Click-Tracking, DSGVO-konformem Unsubscribe und Kampagnen-Statistik.',
|
||||||
en: 'Send newsletters and announcements to contact segments — with a rich-text editor, open/click tracking, GDPR-compliant unsubscribe, and per-campaign stats.',
|
en: 'Send newsletters and announcements to contact segments — with a rich-text editor, open/click tracking, GDPR-compliant unsubscribe, and per-campaign stats.',
|
||||||
},
|
},
|
||||||
icon: APP_ICONS.broadcast,
|
icon: APP_ICONS.broadcasts,
|
||||||
color: '#6366f1',
|
color: '#6366f1',
|
||||||
comingSoon: false,
|
comingSoon: false,
|
||||||
status: 'development',
|
status: 'development',
|
||||||
|
|
@ -1315,9 +1314,6 @@ const APP_URL_OVERRIDES: Partial<Record<AppIconId, { dev: string; prod: string }
|
||||||
mana: { dev: 'http://localhost:5173', prod: 'https://mana.how' },
|
mana: { dev: 'http://localhost:5173', prod: 'https://mana.how' },
|
||||||
// Standalone apps on their own subdomain / port.
|
// Standalone apps on their own subdomain / port.
|
||||||
arcade: { dev: 'http://localhost:5201', prod: 'https://arcade.mana.how' },
|
arcade: { dev: 'http://localhost:5201', prod: 'https://arcade.mana.how' },
|
||||||
// The broadcast module's route is `/broadcasts` (plural) but the icon
|
|
||||||
// + id stay singular to match the `lib/modules/broadcast/` folder.
|
|
||||||
broadcast: { dev: 'http://localhost:5173/broadcasts', prod: 'https://mana.how/broadcasts' },
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export const APP_URLS: Record<AppIconId, { dev: string; prod: string }> = Object.fromEntries(
|
export const APP_URLS: Record<AppIconId, { dev: string; prod: string }> = Object.fromEntries(
|
||||||
|
|
|
||||||
86
scripts/validate-tier-patches.mjs
Normal file
86
scripts/validate-tier-patches.mjs
Normal file
|
|
@ -0,0 +1,86 @@
|
||||||
|
#!/usr/bin/env node
|
||||||
|
/**
|
||||||
|
* Validate that no `LOCAL TIER PATCH` markers leak into the release.
|
||||||
|
*
|
||||||
|
* Background: tier downgrades during local dev are marked with a comment
|
||||||
|
* `// LOCAL TIER PATCH — revert to '<tier>' before release` so devs can
|
||||||
|
* temporarily expose alpha/beta modules to themselves on the guest tier.
|
||||||
|
* If those markers ship to prod every guest sees premium modules.
|
||||||
|
*
|
||||||
|
* This script is informational by default — it lists every marker so the
|
||||||
|
* release engineer sees them. Set MANA_TIER_PATCH_STRICT=1 to fail the
|
||||||
|
* build instead (use this in the release / RC pipeline, NOT in local
|
||||||
|
* validate:all where the markers are intentional).
|
||||||
|
*
|
||||||
|
* Zero deps — runs as plain Node ESM. Uses `git ls-files` so it
|
||||||
|
* automatically respects .gitignore.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { execSync } from 'node:child_process';
|
||||||
|
import { readFileSync } from 'node:fs';
|
||||||
|
import { fileURLToPath } from 'node:url';
|
||||||
|
import { dirname, join } from 'node:path';
|
||||||
|
|
||||||
|
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||||
|
const REPO_ROOT = join(__dirname, '..');
|
||||||
|
|
||||||
|
const STRICT = process.env.MANA_TIER_PATCH_STRICT === '1';
|
||||||
|
const MARKER = 'LOCAL TIER PATCH';
|
||||||
|
|
||||||
|
function listTrackedFiles() {
|
||||||
|
const out = execSync('git ls-files "*.ts" "*.tsx" "*.js" "*.mjs" "*.svelte" "*.json" "*.md"', {
|
||||||
|
cwd: REPO_ROOT,
|
||||||
|
encoding: 'utf8',
|
||||||
|
maxBuffer: 32 * 1024 * 1024,
|
||||||
|
});
|
||||||
|
return out
|
||||||
|
.split('\n')
|
||||||
|
.map((p) => p.trim())
|
||||||
|
.filter(Boolean);
|
||||||
|
}
|
||||||
|
|
||||||
|
function scan() {
|
||||||
|
const hits = [];
|
||||||
|
for (const rel of listTrackedFiles()) {
|
||||||
|
const abs = join(REPO_ROOT, rel);
|
||||||
|
let body;
|
||||||
|
try {
|
||||||
|
body = readFileSync(abs, 'utf8');
|
||||||
|
} catch {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (!body.includes(MARKER)) continue;
|
||||||
|
const lines = body.split('\n');
|
||||||
|
for (let i = 0; i < lines.length; i++) {
|
||||||
|
if (lines[i].includes(MARKER)) {
|
||||||
|
hits.push({ file: rel, line: i + 1, text: lines[i].trim() });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return hits;
|
||||||
|
}
|
||||||
|
|
||||||
|
const hits = scan();
|
||||||
|
|
||||||
|
if (hits.length === 0) {
|
||||||
|
console.log('✓ No LOCAL TIER PATCH markers found.');
|
||||||
|
process.exit(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`Found ${hits.length} LOCAL TIER PATCH marker(s):\n`);
|
||||||
|
for (const h of hits) {
|
||||||
|
console.log(` ${h.file}:${h.line}`);
|
||||||
|
console.log(` ${h.text}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (STRICT) {
|
||||||
|
console.error(
|
||||||
|
'\n✗ MANA_TIER_PATCH_STRICT=1 — refusing to ship while LOCAL TIER PATCH markers are present.\n' +
|
||||||
|
' Revert each marker to the documented production tier before tagging the release.'
|
||||||
|
);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(
|
||||||
|
'\n (informational — not failing. Set MANA_TIER_PATCH_STRICT=1 in release CI to gate.)'
|
||||||
|
);
|
||||||
Loading…
Add table
Add a link
Reference in a new issue