evOo-local/apps/web/src/lib/heute-brief.ts
cynarAI 5ae8f15931 feat(web): Ingo UX — Proaktivität, Oo-Gesicht, dezentes Audio (#76)
* feat(web): Ingo UX — proactivity signals, Oo face richness, soft audio

Priority S: Heute Brief buckets (Needs you ≤3 / Doing / Done), roster doing-verbs
and Braucht-dich chips, sidebar badge on Heute, need-you ping on waiting states.

Priority A/B: Web Audio cues (app-start shimmer, interact tick, success-resolve,
need-you ouh), reaction vocals with cooldown, optional eOo cold-open voice mark.
Oo eyes gain iris/specular detail and asymmetric blink; hands reduced to rest
except punctuation poses; speaking mouth animates.

Co-authored-by: cynarAI <cynarAI@users.noreply.github.com>

* fix(web): biome format and unused import in HeuteBrief

Co-authored-by: cynarAI <cynarAI@users.noreply.github.com>

* fix(web): a11y and import order for Heute badge

Co-authored-by: cynarAI <cynarAI@users.noreply.github.com>

* docs+motion: Motion God craft — pose stagger, audio bus, ducking

Add MOTION-EVOO-CHARACTER.md (Brand Soul brief): eyes lead, mouth ~80%,
hands punctuation, 40–80 ms eye→mouth lag, 220–320 ms springs, SFX LUFS
and duck-under-TTS/PTT, nod mh/Mhh, reduced-motion rules.

Implement motion-tokens, staggered mouthPose in EvooMascot, shared
oo-audio-bus with voice-lane ducking, mint→gold success, hm nod vocal.

Co-authored-by: cynarAI <cynarAI@users.noreply.github.com>

* fix(web): align conversation roster with bot-only sidebar groups after rebase

Co-authored-by: cynarAI <cynarAI@users.noreply.github.com>

---------

Co-authored-by: Cursor Agent <cursoragent@cursor.com>
Co-authored-by: cynarAI <cynarAI@users.noreply.github.com>
2026-09-22 14:39:40 +02:00

140 lines
3.8 KiB
TypeScript

import type { Bot, Group, RunActivityRow } from "@rakazo/contracts";
import { isActionableRun, isDoingStatus, isFailedRoutine } from "./agent-activity";
export type JetztRunItem = {
kind: "run";
run: RunActivityRow;
};
export type JetztUnreadItem = {
kind: "unread";
chatKind: "bot" | "group";
chat: Bot | Group;
};
export type JetztItem = JetztRunItem | JetztUnreadItem;
export type DoingItem = {
kind: "run";
run: RunActivityRow;
};
export type DoneItem = {
kind: "run";
run: RunActivityRow;
};
/** @deprecated Use needsYou — kept for gradual migration in tests. */
export type WeiterItem = {
chatKind: "bot" | "group";
chat: Bot | Group;
};
export type HeuteBriefData = {
/** Needs you — capped at three visible rows. */
needsYou: JetztItem[];
/** Active runs where the agent is working, not blocked. */
doing: DoingItem[];
/** Recently completed runs from today. */
done: DoneItem[];
/** Total actionable count (may exceed visible cap). */
actionCount: number;
/** @deprecated Alias for needsYou — legacy bucket name. */
jetzt: JetztItem[];
/** @deprecated Empty — replaced by doing/done buckets. */
weiter: WeiterItem[];
};
export const HEUTE_NEEDS_YOU_CAP = 3;
export function isToday(iso: string, now = new Date()): boolean {
const date = new Date(iso);
if (Number.isNaN(date.getTime())) return false;
return (
date.getDate() === now.getDate() &&
date.getMonth() === now.getMonth() &&
date.getFullYear() === now.getFullYear()
);
}
export function cleanBriefPreview(raw?: string | null): string {
if (!raw) return "";
let text = raw;
text = text.replace(/^#{1,6}\s+/gm, "");
text = text.replace(/\[(?:to|RECOVERY PROTOCOL|PO)[^\]]*\]/gi, "");
text = text.replace(/[*_`#]/g, "");
text = text.replace(/\\/g, "");
return text.replace(/\s+/g, " ").trim();
}
function runTargetKey(run: RunActivityRow): string {
return run.groupId ? `group:${run.groupId}` : `bot:${run.botId}`;
}
function chatTargetKey(chatKind: "bot" | "group", chatId: string): string {
return chatKind === "group" ? `group:${chatId}` : `bot:${chatId}`;
}
export function buildHeuteBrief(params: {
bots: Bot[];
groups: Group[];
activeRuns: RunActivityRow[];
recentRuns: RunActivityRow[];
now?: Date;
}): HeuteBriefData {
const { bots, groups, activeRuns, recentRuns, now = new Date() } = params;
const needsYouAll: JetztItem[] = [];
const coveredTargets = new Set<string>();
const actionableRuns = [
...activeRuns.filter(isActionableRun),
...recentRuns.filter(isFailedRoutine),
];
const seenRunIds = new Set<string>();
for (const run of actionableRuns) {
if (seenRunIds.has(run.runId)) continue;
seenRunIds.add(run.runId);
needsYouAll.push({ kind: "run", run });
coveredTargets.add(runTargetKey(run));
}
const unreadChats: JetztUnreadItem[] = [
...bots
.filter((bot) => bot.unread)
.map((bot) => ({ kind: "unread" as const, chatKind: "bot" as const, chat: bot })),
...groups
.filter((group) => group.unread)
.map((group) => ({
kind: "unread" as const,
chatKind: "group" as const,
chat: group,
})),
];
for (const item of unreadChats) {
const key = chatTargetKey(item.chatKind, item.chat.id);
if (coveredTargets.has(key)) continue;
needsYouAll.push(item);
coveredTargets.add(key);
}
const doing: DoingItem[] = activeRuns
.filter((run) => isDoingStatus(run.status))
.map((run) => ({ kind: "run" as const, run }));
const done: DoneItem[] = recentRuns
.filter((run) => run.status === "completed" && isToday(run.updatedAt, now))
.slice(0, 5)
.map((run) => ({ kind: "run" as const, run }));
const needsYou = needsYouAll.slice(0, HEUTE_NEEDS_YOU_CAP);
return {
needsYou,
doing,
done,
actionCount: needsYouAll.length,
jetzt: needsYouAll,
weiter: [],
};
}