Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
27 commits
Select commit Hold shift + click to select a range
03efccf
C1-S9-111: reminders and the Communications Hub in the reader's language
claude Sep 27, 2026
bd71dbc
finalaudit: C1-S9-111; hand-off table gains C1-S9-108…111 for lanes A…
claude Sep 27, 2026
c1e0fe3
Merge origin/main into claude/bubaly-repo-connect-45d8k6
claude Sep 27, 2026
ad09f31
finalaudit: correct the cause given in the last merge message
claude Sep 27, 2026
4feb26a
C1-S9-112: the finances overview in the reader's language; one catego…
claude Sep 27, 2026
4816ea6
C1-S9-113: the assistant greets, suggests and labels in the reader's …
claude Sep 27, 2026
d3adc5f
C1-S9-114: Trust & Permissions in the reader's language
claude Sep 27, 2026
601bfec
Tests follow the translated copy; trust counts approvals as a plural
claude Sep 27, 2026
06cef77
Merge remote-tracking branch 'origin/main' into claude/bubaly-repo-co…
claude Sep 27, 2026
3d88570
C1-S9-115: Front Desk call statuses and classifications in the reader…
claude Sep 27, 2026
0b28d03
C1-S9-116: grocery categories, list presets and pantry stats in the r…
claude Sep 27, 2026
79b3b10
C1-S9-117: recipes in the reader's language
claude Sep 27, 2026
425f892
C1-S9-118: tasks in the reader's language, including the first list a…
claude Sep 27, 2026
a8d3f5c
C1-S9-119: the family page's roles, badges and shared-information car…
claude Sep 27, 2026
c6de0ab
Briefing: drop the dead English twins beside its catalogue keys
claude Sep 27, 2026
377b88e
C1-S9-120: documents and relationship labels in the reader's language
claude Sep 27, 2026
754d6c9
Merge origin/main (e96e745a, #585) into claude/bubaly-repo-connect-45…
claude Sep 27, 2026
af93032
finalaudit: C1-S9-120 ceiling after the main merge
claude Sep 27, 2026
51019b5
C1-S9-121: concierge, subscriptions and expenses in the reader's lang…
claude Sep 27, 2026
48b7816
Merge remote-tracking branch 'origin/main' into claude/bubaly-repo-co…
claude Sep 27, 2026
bef02e3
C1-S9-122: family signals, social feed and voice capture in the reade…
claude Sep 27, 2026
e8294c3
C1-S9-123: renewals and messages in the reader's language
claude Sep 27, 2026
d5bea07
messages: the first-conversation effect lists tr among its dependencies
claude Sep 27, 2026
02e4663
C1-S9-124: insurance in the reader's language
claude Sep 27, 2026
53d6f17
C1-S9-125: password categories and weekly load/stress labels in the r…
claude Sep 27, 2026
263cfc1
C1-S9-126: medical and dental records in the reader's language
claude Sep 27, 2026
e2a17ed
Merge origin/main (4123f13d: #601, #608) into claude/bubaly-repo-conn…
claude Sep 27, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
109 changes: 60 additions & 49 deletions components/modules/assistant-module.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -34,31 +34,34 @@ import type { MicStatus } from '@/lib/voice/mic-flow';
import { VOICE_MODES } from '@/lib/ai/voice';
import { parsePrefillQuery } from '@/lib/ai/prefill';
import { parseAssistantStreamEvent, runStatusCard, structuredContentFrom, type ResultCard } from '@/lib/ai/result-cards';
import { useTranslations } from '@/components/i18n/locale-provider';
import { usePlural, useTranslations } from '@/components/i18n/locale-provider';

// Quick-suggestion chips shown above an active conversation.
// Quick-suggestion chips shown above an active conversation. Each holds a
// catalogue key: the chip is shown, and sent as the reader's own message, in
// the reader's language.
const CHIPS = [
[CalendarDays, "What's happening today?"],
[UtensilsCrossed, 'Plan dinners for the week'],
[Sparkles, 'Add soccer practice every Tuesday'],
[ListChecks, 'Create chores for the kids'],
[School, 'Summarize our week'],
[CalendarDays, 'assistantModule.chip.whatsHappeningToday'],
[UtensilsCrossed, 'assistantModule.chip.planDinners'],
[Sparkles, 'assistantModule.chip.addSoccerPractice'],
[ListChecks, 'assistantModule.chip.createChores'],
[School, 'assistantModule.chip.summarizeOurWeek'],
] as const;

// "Popular requests" cards on the welcome hero (icon + title + sub + prompt).
const POPULAR: { icon: React.ComponentType<{ className?: string }>; title: string; sub: string; prompt: string }[] = [
{ icon: CalendarDays, title: "Today's plan", sub: "what's on our schedule", prompt: "What's on our schedule today?" },
{ icon: UtensilsCrossed, title: 'Plan dinners', sub: 'for the whole week', prompt: 'Plan dinners for this week' },
{ icon: ListChecks, title: 'Assign chores', sub: 'to the kids', prompt: 'Create chores for the kids this week' },
{ icon: ShoppingCart, title: 'Grocery list', sub: 'from our meal plan', prompt: 'Build a grocery list from our meal plan' },
{ icon: Bell, title: 'Set a reminder', sub: 'so nothing slips', prompt: 'Remind me to order the camp forms' },
const POPULAR: { icon: React.ComponentType<{ className?: string }>; id: string }[] = [
{ icon: CalendarDays, id: 'todaysPlan' },
{ icon: UtensilsCrossed, id: 'planDinners' },
{ icon: ListChecks, id: 'assignChores' },
{ icon: ShoppingCart, id: 'groceryList' },
{ icon: Bell, id: 'setAReminder' },
];
const popularKey = (id: string, part: 'title' | 'sub' | 'prompt') => `assistantModule.popular.${id}.${part}`;

const TRY_ASKING = [
{ icon: CalendarDays, text: "What's on our schedule today?" },
{ icon: UtensilsCrossed, text: 'Plan dinners for this week' },
{ icon: ShoppingCart, text: 'Build a grocery list from our meal plan' },
{ icon: ListChecks, text: 'What chores are due this week?' },
{ icon: CalendarDays, textKey: 'assistantModule.popular.todaysPlan.prompt' },
{ icon: UtensilsCrossed, textKey: 'assistantModule.popular.planDinners.prompt' },
{ icon: ShoppingCart, textKey: 'assistantModule.popular.groceryList.prompt' },
{ icon: ListChecks, textKey: 'assistantModule.tryAsking.choresDueThisWeek' },
];

type ChatAction = { name: string; ok: boolean; summary: string };
Expand All @@ -75,17 +78,20 @@ function newConversationId() {
});
}

type Tr = (key: string, params?: Record<string, string | number>) => string;
type Plural = (key: string, count: number, params?: Record<string, string | number>) => string;

/** One-line label for the outcome chip in the thread that points at the card in the plan pane. */
export function cardChipLabel(card: ResultCard): string {
export function cardChipLabel(card: ResultCard, t: Tr, plural: Plural): string {
switch (card.kind) {
case 'meal_plan': return `Meal plan · ${card.days.length} ${card.days.length === 1 ? 'day' : 'days'}`;
case 'calendar_conflict': return card.conflicts.length ? `${card.conflicts.length} ${card.conflicts.length === 1 ? 'conflict' : 'conflicts'}` : 'No conflicts';
case 'budget_analysis': return 'Spending';
case 'vacation_prep': return 'Trip prep';
case 'task_group': return `${card.tasks.length} ${card.tasks.length === 1 ? 'task' : 'tasks'}`;
case 'grocery_list': return `Grocery list · ${card.items.length}`;
case 'readiness': return `Readiness ${Math.round(card.score)}`;
case 'approval': return 'Needs your approval';
case 'meal_plan': return plural('assistantModule.chipLabel.mealPlanDays', card.days.length);
case 'calendar_conflict': return card.conflicts.length ? plural('assistantModule.chipLabel.conflicts', card.conflicts.length) : t('assistantModule.chipLabel.noConflicts');
case 'budget_analysis': return t('assistantModule.chipLabel.spending');
case 'vacation_prep': return t('assistantModule.chipLabel.tripPrep');
case 'task_group': return plural('assistantModule.chipLabel.tasks', card.tasks.length);
case 'grocery_list': return t('assistantModule.chipLabel.groceryList', { count: card.items.length });
case 'readiness': return t('assistantModule.chipLabel.readiness', { score: Math.round(card.score) });
case 'approval': return t('assistantModule.chipLabel.needsYourApproval');
case 'run_status': return card.title;
default: return card.title;
}
Expand All @@ -99,8 +105,9 @@ export function withRunCards(cards: ResultCard[], runIds: string[]): ResultCard[

export function AssistantModule() {
const t = useTranslations();
const plural = usePlural();
const { family, selfMember, role } = useApp();
const firstName = (selfMember?.display_name || 'there').split(' ')[0];
const firstName = (selfMember?.display_name ?? '').trim().split(' ')[0];
const canDecide = isManager(role);
const desktop = useDesktop();

Expand All @@ -114,8 +121,12 @@ export function AssistantModule() {

const greeting = () => {
const h = new Date().getHours();
const g = h < 12 ? 'Good morning' : h < 18 ? 'Good afternoon' : 'Good evening';
return [{ id: 'init', role: 'assistant' as const, content: `${g}, ${firstName}! I can help you plan your week, schedule events, add chores, build your grocery list, set reminders, and more — just ask.` }];
const part = h < 12 ? 'morning' : h < 18 ? 'afternoon' : 'evening';
// Without a name the greeting has none, rather than an English "there".
const content = firstName
? t(`assistantModule.greeting.${part}`, { name: firstName })
: t(`assistantModule.greeting.${part}NoName`);
return [{ id: 'init', role: 'assistant' as const, content }];
};

const [messages, setMessages] = useState<Message[]>([]);
Expand Down Expand Up @@ -143,10 +154,10 @@ export function AssistantModule() {

// Context rail data
const [glance, setGlance] = useState<GlanceItem[]>([
{ icon: CalendarDays, value: '—', label: 'Events today' },
{ icon: CheckCircle2, value: '—', label: 'Tasks due' },
{ icon: Bell, value: '—', label: 'Reminders due' },
{ icon: Pill, value: '—', label: 'Active meds' },
{ icon: CalendarDays, value: '—', label: t('assistantModule.glance.eventsToday') },
{ icon: CheckCircle2, value: '—', label: t('assistantModule.glance.tasksDue') },
{ icon: Bell, value: '—', label: t('assistantModule.glance.remindersDue') },
{ icon: Pill, value: '—', label: t('assistantModule.glance.activeMeds') },
]);
const [upcoming, setUpcoming] = useState<UpcomingEvent[]>([]);
const [activity, setActivity] = useState<ActivityItem[]>([]);
Expand Down Expand Up @@ -192,15 +203,15 @@ export function AssistantModule() {
setRailError(null);
const todayEvts = todayRes.data ?? [];
setGlance([
{ icon: CalendarDays, value: String(todayEvts.length), label: 'Events today' },
{ icon: CheckCircle2, value: String(choresRes.count ?? 0), label: 'Tasks due' },
{ icon: Bell, value: String(remindersRes.count ?? 0), label: 'Reminders due' },
{ icon: Pill, value: String(medsRes.count ?? 0), label: 'Active meds' },
{ icon: CalendarDays, value: String(todayEvts.length), label: t('assistantModule.glance.eventsToday') },
{ icon: CheckCircle2, value: String(choresRes.count ?? 0), label: t('assistantModule.glance.tasksDue') },
{ icon: Bell, value: String(remindersRes.count ?? 0), label: t('assistantModule.glance.remindersDue') },
{ icon: Pill, value: String(medsRes.count ?? 0), label: t('assistantModule.glance.activeMeds') },
]);
setUpcoming(upcomingRes.data ?? []);
setActivity(todayEvts.slice(0, 3).map((e, i) => ({
icon: CalendarDays,
text: `${e.title} added to calendar`,
text: t('assistantModule.addedToCalendar', { title: e.title }),
time: fmtRelative(e.created_at),
color: ['text-emerald-400', 'text-orange-400', 'text-violet-400'][i] ?? 'text-violet-400',
})));
Expand Down Expand Up @@ -386,7 +397,7 @@ export function AssistantModule() {
if (finalText.trim() && voice.shouldSpeak()) void voice.speak(finalText);
} catch (error) {
console.error('[assistant] request failed', error);
patchReply((m) => ({ ...m, content: m.content || 'Something went wrong. Please try again.' }));
patchReply((m) => ({ ...m, content: m.content || t('assistantModule.somethingWentWrong') }));
} finally {
setLoading(false);
void loadConversations(); // titles/order update after the turn persists
Expand Down Expand Up @@ -417,15 +428,15 @@ export function AssistantModule() {
const thread = (
<>
<div className="mt-4 flex gap-2.5 overflow-x-auto scrollbar-none">
{CHIPS.map(([Icon, label]) => (
{CHIPS.map(([Icon, key]) => { const label = t(key); return (
<button
key={label} type="button" onClick={() => void send(label)}
key={key} type="button" onClick={() => void send(label)}
className="focus-ring inline-flex h-10 shrink-0 items-center gap-2 rounded-full border border-border bg-surface/40 px-4 text-sm text-fg transition hover:border-brand/40 hover:bg-elevated"
>
<Icon className="h-4 w-4 text-brand-text" aria-hidden />
{label}
</button>
))}
); })}
</div>

{threadError && (
Expand Down Expand Up @@ -476,7 +487,7 @@ export function AssistantModule() {
highlightId === id ? 'border-brand/60 bg-brand/15 text-brand-text' : 'border-brand/30 bg-brand/10 text-brand-text',
)}
>
<LayoutList className="h-3 w-3" aria-hidden /> {cardChipLabel(card)}
<LayoutList className="h-3 w-3" aria-hidden /> {cardChipLabel(card, t, plural)}
</button>
</li>
);
Expand Down Expand Up @@ -518,20 +529,20 @@ export function AssistantModule() {
<div className="mt-6 w-full max-w-3xl">
<p className="mb-3 text-sm font-bold tracking-wide text-fg/90">{t('assistant.popularRequests')}</p>
<div className="grid grid-cols-2 gap-3 sm:grid-cols-3 lg:grid-cols-5">
{POPULAR.map(({ icon: Icon, title, sub, prompt }) => (
{POPULAR.map(({ icon: Icon, id }) => (
<button
key={title}
key={id}
type="button"
onClick={() => void send(prompt)}
onClick={() => void send(t(popularKey(id, 'prompt')))}
disabled={loading}
className="ai-suggest-card group flex items-start gap-2.5 p-3 text-left disabled:opacity-50"
>
<span className="grid h-9 w-9 shrink-0 place-items-center rounded-xl bg-brand/10 text-brand-text transition group-hover:bg-brand/20">
<Icon className="h-4 w-4" aria-hidden />
</span>
<span className="min-w-0">
<span className="block truncate text-sm font-bold text-fg">{title}</span>
<span className="block truncate text-xs text-muted">{sub}</span>
<span className="block truncate text-sm font-bold text-fg">{t(popularKey(id, 'title'))}</span>
<span className="block truncate text-xs text-muted">{t(popularKey(id, 'sub'))}</span>
</span>
</button>
))}
Expand Down Expand Up @@ -646,7 +657,7 @@ export function AssistantModule() {
glance={glance}
upcoming={upcoming}
activity={activity}
prompts={TRY_ASKING}
prompts={TRY_ASKING.map(({ icon, textKey }) => ({ icon, text: t(textKey) }))}
loading={railLoading}
error={railError}
onRetry={() => void loadRail()}
Expand Down
14 changes: 1 addition & 13 deletions components/modules/billing-module.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,7 @@ import {
} from '@/lib/billing/plans';
import { cn } from '@/lib/utils/cn';
import type { Tables, SubscriptionStatus, AccountType, TransactionType, BudgetPeriod, BillStatus } from '@/lib/database.types';
import { categoryLabel } from '@/lib/finance/category-label';
import { useLocale, useTranslations } from '@/components/i18n/locale-provider';
import { FamilyDeliveredValue } from '@/components/billing/family-delivered-value';
import type { LocaleCode } from '@/lib/i18n/locales';
Expand Down Expand Up @@ -261,19 +262,6 @@ function memberAge(birthday: string | null): number | null {
return age >= 0 && age < 130 ? age : null;
}

// The stored category is the English word (it is data, and existing rows hold
// it); what a reader sees is its label. Audit C1-S9-109.
const CATEGORY_KEY: Record<string, string> = {
Housing: 'housing', Groceries: 'groceries', Dining: 'dining', Transport: 'transport', Utilities: 'utilities',
Entertainment: 'entertainment', Health: 'health', Shopping: 'shopping', Subscriptions: 'subscriptions', Insurance: 'insurance',
Education: 'education', 'Auto & Gas': 'autoGas', 'Personal Care': 'personalCare', Gifts: 'gifts', Other: 'other',
};
/** A known category in the reader's language; a family's own category as they typed it. */
function categoryLabel(tr: (key: string) => string, category: string | null | undefined): string {
const c = category || 'Other';
return CATEGORY_KEY[c] ? tr(`billingModule.category.${CATEGORY_KEY[c]}`) : c;
}

const EXPENSE_CATEGORIES = [
'Housing', 'Groceries', 'Dining', 'Transport', 'Utilities',
'Entertainment', 'Health', 'Shopping', 'Subscriptions', 'Insurance',
Expand Down
26 changes: 12 additions & 14 deletions components/modules/briefing-module.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -96,13 +96,13 @@ function CategoryBar({ label, score, icon }: OpsCategory) {
);
}

const DOMAIN_META: Record<ConciergeDomain, { emoji: string; label: string; href: string }> = {
bill: { emoji: '💵', label: 'Bill', href: '/dashboard/billing' },
medication: { emoji: '💊', label: 'Medication', href: '/dashboard/medications' },
maintenance: { emoji: '🔧', label: 'Maintenance', href: '/dashboard/home' },
warranty: { emoji: '🛡️', label: 'Warranty', href: '/dashboard/home' },
trip: { emoji: '✈️', label: 'Trip', href: '/dashboard/vacations' },
pantry: { emoji: '🥫', label: 'Pantry', href: '/dashboard/pantry' },
const DOMAIN_META: Record<ConciergeDomain, { emoji: string; href: string }> = {
bill: { emoji: '💵', href: '/dashboard/billing' },
medication: { emoji: '💊', href: '/dashboard/medications' },
maintenance: { emoji: '🔧', href: '/dashboard/home' },
warranty: { emoji: '🛡️', href: '/dashboard/home' },
trip: { emoji: '✈️', href: '/dashboard/vacations' },
pantry: { emoji: '🥫', href: '/dashboard/pantry' },
};
const DIGEST_URGENCY: Record<ConciergeUrgency, { labelKey: string; cls: string; dot: string }> = {
overdue: { labelKey: 'briefingView.urgencyOverdue', cls: 'text-rose-400 bg-rose-500/10 border-rose-500/30', dot: 'bg-rose-400' },
Expand Down Expand Up @@ -180,19 +180,17 @@ function NeedsAttention({ digest }: { digest: ConciergeDigest }) {
type DecisionMeta = {
icon: React.ComponentType<{ className?: string }>;
iconBg: string;
cta: string;
ctaKey: string;
subtitle: string;
subtitleKey: string;
};

const DECISION_META: Record<string, DecisionMeta> = {
approval: { icon: ShieldCheck, iconBg: 'bg-amber-500/15 text-amber-400', cta: 'Review', ctaKey: 'briefing.decisionCtaReview', subtitle: 'A family member is waiting on your approval.', subtitleKey: 'briefing.decisionSubtitleMoney' },
run_awaiting_approval: { icon: ShieldCheck, iconBg: 'bg-amber-500/15 text-amber-400', cta: 'Review', ctaKey: 'briefing.decisionCtaReview', subtitle: 'Bubaly is ready and waiting for the go-ahead.', subtitleKey: 'briefing.decisionSubtitleOk' },
run_awaiting_answer: { icon: MessageCircleQuestion, iconBg: 'bg-amber-500/15 text-amber-400', cta: 'Answer', ctaKey: 'briefing.decisionCtaAnswer', subtitle: 'One quick answer and Bubaly can finish this.', subtitleKey: 'briefing.decisionSubtitleAnswer' },
recommendation: { icon: Lightbulb, iconBg: 'bg-brand/15 text-brand-text', cta: 'View', ctaKey: 'briefing.decisionCtaView', subtitle: 'Bubaly suggests this; accept it and it gets done.', subtitleKey: 'briefing.decisionSubtitleRecommendation' },
approval: { icon: ShieldCheck, iconBg: 'bg-amber-500/15 text-amber-400', ctaKey: 'briefing.decisionCtaReview', subtitleKey: 'briefing.decisionSubtitleMoney' },
run_awaiting_approval: { icon: ShieldCheck, iconBg: 'bg-amber-500/15 text-amber-400', ctaKey: 'briefing.decisionCtaReview', subtitleKey: 'briefing.decisionSubtitleOk' },
run_awaiting_answer: { icon: MessageCircleQuestion, iconBg: 'bg-amber-500/15 text-amber-400', ctaKey: 'briefing.decisionCtaAnswer', subtitleKey: 'briefing.decisionSubtitleAnswer' },
recommendation: { icon: Lightbulb, iconBg: 'bg-brand/15 text-brand-text', ctaKey: 'briefing.decisionCtaView', subtitleKey: 'briefing.decisionSubtitleRecommendation' },
};
const DEFAULT_DECISION_META: DecisionMeta = { icon: Bell, iconBg: 'bg-brand/15 text-brand-text', cta: 'View', ctaKey: 'briefing.decisionCtaView', subtitle: '', subtitleKey: '' };
const DEFAULT_DECISION_META: DecisionMeta = { icon: Bell, iconBg: 'bg-brand/15 text-brand-text', ctaKey: 'briefing.decisionCtaView', subtitleKey: '' };

/** Cards shown in the brief; the rest are one tap away on Home, which lists them all. */
const DECISIONS_SHOWN = 6;
Expand Down
Loading
Loading