Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
11 changes: 7 additions & 4 deletions app/(app)/dashboard/contacts/[id]/actions.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
'use server';

import { revalidatePath } from 'next/cache';
import { getTranslations } from '@/lib/i18n/server';
import { getLocaleContext, getTranslations } from '@/lib/i18n/server';
import { requireUserContext } from '@/lib/supabase/auth';
import { createServer } from '@/lib/supabase/server';
import { describeActionError } from '@/lib/supabase/errors';
Expand Down Expand Up @@ -99,6 +99,7 @@ export async function draftReconnectMessageAction(
} catch { /* table not present in this env */ }

const timeline = buildContactTimeline({
t,
interactions: (rawInts ?? []).map((i): LoggedInteraction => ({
id: i.id, kind: i.kind as LoggedInteraction['kind'], occurred_on: i.occurred_on,
title: i.title, note: i.note, amount: i.amount,
Expand All @@ -107,20 +108,22 @@ export async function draftReconnectMessageAction(
birthdayMonth: contact.birthday_month,
birthdayDay: contact.birthday_day,
});
const health = contactHealth(timeline, contact.name);
const health = contactHealth(timeline, contact.name, t);

const historyLines = timeline.slice(0, 8)
.map((e) => `- ${e.date}: ${e.title}${e.detail ? ` (${e.detail})` : ''}`)
.join('\n') || '- (no logged history yet)';

const toneWord = tone === 'brief' ? 'short and low-key' : tone === 'playful' ? 'light and playful' : 'warm and genuine';
const { locale } = await getLocaleContext();
const system =
'You help a busy parent write a ready-to-send message to reconnect with someone in their life ' +
'(family, friend, coach, caregiver). Ground the message ONLY in the provided history — never invent ' +
'shared events, names, dates, plans, or feelings that aren’t there. If you need a detail the notes ' +
'don’t give, use one brief bracketed placeholder like [day that works]. Write it from the family to ' +
'the contact, first person. Keep it under 60 words, natural (like a real text), no subject line, no ' +
'sign-off block — just the message. Return ONLY the message text.';
'sign-off block — just the message. Return ONLY the message text. ' +
`Write the message in the selected locale: ${locale.code}.`;
const user =
`Contact: ${contact.name}${contact.relationship ? ` (${contact.relationship})` : ''}\n` +
`Time since last contact: ${health.daysSince == null ? 'no logged history' : `${health.daysSince} days`}\n` +
Expand All @@ -133,7 +136,7 @@ export async function draftReconnectMessageAction(
// with is not something the request ledger needs to carry.
const message = await withAiRequest(
scopeFromUserContext(ctx, supabase),
{ feature: 'contacts.reconnect', text: 'Draft a reconnect message' },
{ feature: 'contacts.reconnect', text: t('contactTimeline.draftRequestTitle') },
async (obs) => {
const provider = await resolveProvider();
const completion = await provider.complete({ system, messages: [{ role: 'user', content: user }], tools: [], maxTokens: 220 });
Expand Down
4 changes: 3 additions & 1 deletion app/(app)/dashboard/contacts/[id]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ async function ReadFailure() {
* relationship-health read (last touch vs. this relationship's natural cadence).
*/
export default async function ContactTimelinePage({ params }: { params: Promise<{ id: string }> }) {
const t = await getTranslations();
const { id } = await params;
const ctx = await requireUserContext();
const supabase = await createServer();
Expand Down Expand Up @@ -80,6 +81,7 @@ export default async function ContactTimelinePage({ params }: { params: Promise<
}

const timeline = buildContactTimeline({
t,
interactions: interactions.map((i): LoggedInteraction => ({
id: i.id, kind: i.kind as LoggedInteraction['kind'], occurred_on: i.occurred_on,
title: i.title, note: i.note, amount: i.amount,
Expand All @@ -88,7 +90,7 @@ export default async function ContactTimelinePage({ params }: { params: Promise<
birthdayMonth: contact.birthday_month,
birthdayDay: contact.birthday_day,
});
const health = contactHealth(timeline, contact.name);
const health = contactHealth(timeline, contact.name, t);

return (
<ContactTimelineModule
Expand Down
53 changes: 30 additions & 23 deletions components/modules/contact-timeline-module.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,32 +11,39 @@ import {
} from 'lucide-react';
import type { Tables } from '@/lib/database.types';
import type { TimelineEntry, ContactHealth, InteractionKind } from '@/lib/contacts/timeline';
import { INTERACTION_LABEL } from '@/lib/contacts/timeline';
import { INTERACTION_LABEL_KEY } from '@/lib/contacts/timeline';
import {
logInteractionAction, deleteInteractionAction, draftReconnectMessageAction,
} from '@/app/(app)/dashboard/contacts/[id]/actions';
import { cn } from '@/lib/utils/cn';
import { useTranslations } from '@/components/i18n/locale-provider';
import { useLocale, useTranslations } from '@/components/i18n/locale-provider';

type Tone = 'warm' | 'brief' | 'playful';
const TONES: { key: Tone; label: string }[] = [
{ key: 'warm', label: 'Warm' }, { key: 'brief', label: 'Brief' }, { key: 'playful', label: 'Playful' },
const TONES: { key: Tone; labelKey: string }[] = [
{ key: 'warm', labelKey: 'contactTimeline.toneWarm' }, { key: 'brief', labelKey: 'contactTimeline.toneBrief' }, { key: 'playful', labelKey: 'contactTimeline.tonePlayful' },
];

const ENTRY_ICON: Record<string, typeof Phone> = {
visit: Users, call: Phone, message: MessageSquare, gift: Gift,
favor: HandHeart, note: StickyNote, communication: Mail, birthday: Cake,
};

const HEALTH_STYLE: Record<ContactHealth['status'], { chip: string; label: string }> = {
fresh: { chip: 'bg-emerald-500/15 text-emerald-300', label: 'In good touch' },
due: { chip: 'bg-amber-500/15 text-amber-300', label: 'Due for a touch' },
overdue: { chip: 'bg-rose-500/15 text-rose-300', label: 'Overdue' },
no_history: { chip: 'bg-white/[0.08] text-muted', label: 'No history yet' },
const HEALTH_STYLE: Record<ContactHealth['status'], { chip: string; labelKey: string }> = {
fresh: { chip: 'bg-emerald-500/15 text-emerald-300', labelKey: 'contactTimeline.statusFresh' },
due: { chip: 'bg-amber-500/15 text-amber-300', labelKey: 'contactTimeline.statusDue' },
overdue: { chip: 'bg-rose-500/15 text-rose-300', labelKey: 'reminders.overdue' },
no_history: { chip: 'bg-white/[0.08] text-muted', labelKey: 'contactTimeline.statusNoHistory' },
};

const KIND_OPTIONS: InteractionKind[] = ['visit', 'call', 'message', 'gift', 'favor', 'note'];

/** Stored calendar dates have no time zone; format them without shifting days. */
function displayDate(value: string, locale: string): string {
const date = new Date(`${value}T00:00:00Z`);
if (!/^\d{4}-\d{2}-\d{2}$/.test(value) || !Number.isFinite(date.getTime()) || date.toISOString().slice(0, 10) !== value) return value;
return date.toLocaleDateString(locale, { year: 'numeric', month: 'short', day: 'numeric', timeZone: 'UTC' });
}

export function ContactTimelineModule({
contact, timeline, health, interactionIds,
}: {
Expand All @@ -45,8 +52,8 @@ export function ContactTimelineModule({
health: ContactHealth;
interactionIds: string[];
}) {
const i18nT = useTranslations();
const tr = useTranslations();
const locale = useLocale().code;
const [composerOpen, setComposerOpen] = useState(false);
const [pending, startTransition] = useTransition();
const [busyId, setBusyId] = useState<string | null>(null);
Expand All @@ -73,16 +80,16 @@ export function ContactTimelineModule({
<div className="min-w-0">
<h1 className="text-2xl font-black sm:text-3xl">{contact.name}</h1>
<p className="mt-1 text-sm text-muted">
{[contact.relationship, contact.organization, contact.specialty].filter(Boolean).join(' · ') || 'Family contact'}
{contact.birthday_month && contact.birthday_day ? ` · 🎂 ${contact.birthday_month}/${contact.birthday_day}` : ''}
{[contact.relationship, contact.organization, contact.specialty].filter(Boolean).join(' · ') || tr('contactTimeline.familyContact')}
{contact.birthday_month && contact.birthday_day ? ` · 🎂 ${new Date(Date.UTC(2000, contact.birthday_month - 1, contact.birthday_day)).toLocaleDateString(locale, { month: 'short', day: 'numeric', timeZone: 'UTC' })}` : ''}
</p>
</div>
<button
onClick={() => setComposerOpen((v) => !v)}
className="inline-flex h-10 items-center gap-2 rounded-xl bg-brand px-4 text-sm font-bold text-brand-fg transition hover:opacity-90"
>
{composerOpen ? <X className="h-4 w-4" /> : <Plus className="h-4 w-4" />}
{composerOpen ? 'Close' : 'Log a touch'}
{composerOpen ? tr('home.close') : tr('contactTimeline.logTouch')}
</button>
</header>

Expand All @@ -92,11 +99,11 @@ export function ContactTimelineModule({
<span className="grid h-9 w-9 place-items-center rounded-xl bg-brand/12 ring-1 ring-brand/25">
<HeartPulse className="h-5 w-5 text-brand-text" />
</span>
<span className={cn('rounded-full px-2.5 py-1 text-[11px] font-bold uppercase tracking-wide', hs.chip)}>{hs.label}</span>
<span className={cn('rounded-full px-2.5 py-1 text-[11px] font-bold uppercase tracking-wide', hs.chip)}>{tr(hs.labelKey)}</span>
{health.daysSince != null && (
<span className="text-xs text-muted">
{tr('contactTimeline.lastTouch')} {health.daysSince === 0 ? 'today' : `${health.daysSince}d ago`}
{health.cadenceDays ? ` · usual rhythm ~${health.cadenceDays}d` : ''}
{tr('contactTimeline.lastTouch')} {new Intl.RelativeTimeFormat(locale, { numeric: 'auto' }).format(-health.daysSince, 'day')}
{health.cadenceDays ? ` · ${tr('contactTimeline.usualRhythm', { days: health.cadenceDays })}` : ''}
</span>
)}
</div>
Expand All @@ -116,13 +123,13 @@ export function ContactTimelineModule({
<label key={k} className="cursor-pointer">
<input type="radio" name="kind" value={k} defaultChecked={k === 'visit'} className="peer sr-only" />
<span className="inline-flex h-8 items-center rounded-full border border-border px-3 text-xs font-semibold text-muted transition peer-checked:border-brand peer-checked:bg-brand/15 peer-checked:text-brand-text">
{INTERACTION_LABEL[k]}
{tr(INTERACTION_LABEL_KEY[k])}
</span>
</label>
))}
</div>
<input
name="title" required placeholder={i18nT('contactTimeline.whatHappenedEGSundayDinner')}
name="title" required placeholder={tr('contactTimeline.whatHappenedEGSundayDinner')}
className="mt-3 h-10 w-full rounded-xl border border-border bg-bg px-3 text-sm text-fg outline-none ring-brand/50 placeholder:text-muted focus:ring-2"
/>
<div className="mt-2 flex flex-col gap-2 sm:flex-row">
Expand Down Expand Up @@ -165,8 +172,8 @@ export function ContactTimelineModule({
<div className="min-w-0 flex-1 rounded-2xl border border-border bg-surface p-3">
<div className="flex items-start justify-between gap-2">
<div className="min-w-0">
<p className="text-sm font-semibold">{e.title}{e.amount != null ? ` · $${e.amount}` : ''}</p>
<p className="text-[11px] text-muted">{e.date}</p>
<p className="text-sm font-semibold">{e.title}{e.amount != null ? ` · ${new Intl.NumberFormat(locale, { style: 'currency', currency: 'USD' }).format(e.amount)}` : ''}</p>
<p className="text-[11px] text-muted">{displayDate(e.date, locale)}</p>
</div>
{deletable && (
<button
Expand Down Expand Up @@ -244,7 +251,7 @@ function ReconnectDrafter({ contactId, name }: { contactId: string; name: string
className={cn('h-7 rounded-full px-2.5 text-[11px] font-semibold transition disabled:opacity-60',
tone === t.key ? 'bg-brand/20 text-brand-text' : 'text-muted hover:bg-elevated')}
>
{t.label}
{tr(t.labelKey)}
</button>
))}
<button onClick={() => setOpen(false)} aria-label={tr('contactTimeline.closeDraft')} className="rounded-lg p-1 text-muted hover:bg-elevated">
Expand All @@ -256,14 +263,14 @@ function ReconnectDrafter({ contactId, name }: { contactId: string; name: string
{error ? (
<p className="mt-2 text-xs text-rose-300">{error}</p>
) : pending && !message ? (
<p className="mt-2 inline-flex items-center gap-1.5 text-xs text-muted"><Loader2 className="h-3.5 w-3.5 animate-spin" /> Writing…</p>
<p className="mt-2 inline-flex items-center gap-1.5 text-xs text-muted"><Loader2 className="h-3.5 w-3.5 animate-spin" /> {tr('contactTimeline.writing')}</p>
) : message ? (
<>
<p className={cn('mt-2 whitespace-pre-wrap text-sm leading-relaxed text-fg', pending && 'opacity-50')}>{message}</p>
<div className="mt-2.5 flex items-center gap-2">
<button onClick={copy}
className="inline-flex h-8 items-center gap-1.5 rounded-lg bg-brand px-3 text-xs font-bold text-brand-fg transition hover:opacity-90">
{copied ? <CheckIcon className="h-3.5 w-3.5" /> : <Copy className="h-3.5 w-3.5" />} {copied ? 'Copied' : 'Copy'}
{copied ? <CheckIcon className="h-3.5 w-3.5" /> : <Copy className="h-3.5 w-3.5" />} {copied ? tr('adminMarketingAssistant.copied') : tr('family.copy')}
</button>
<button onClick={() => draft(tone)} disabled={pending}
className="inline-flex h-8 items-center gap-1.5 rounded-lg border border-border px-3 text-xs font-semibold text-muted transition hover:bg-elevated disabled:opacity-60">
Expand Down
40 changes: 26 additions & 14 deletions lib/contacts/timeline.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
// No Supabase / React imports.

export type InteractionKind = 'visit' | 'call' | 'message' | 'gift' | 'favor' | 'note';
export type TimelineTranslator = (key: string, params?: Record<string, string | number>) => string;

export interface LoggedInteraction {
id: string;
Expand Down Expand Up @@ -57,6 +58,7 @@ function toDate(s: string): Date {

/** Merge logged interactions + inbox communications (+ this year's birthday) into one timeline. */
export function buildContactTimeline(input: {
t: TimelineTranslator;
interactions: LoggedInteraction[];
communications: CommunicationLike[];
birthdayMonth?: number | null;
Expand All @@ -82,7 +84,9 @@ export function buildContactTimeline(input: {
id: `comm-${c.id}`,
kind: 'communication',
date: ymd(toDate(c.received_at)),
title: c.subject || `${c.direction === 'outbound' ? 'Reached out' : 'Heard from them'} · ${c.channel}`,
title: c.subject || input.t(c.direction === 'outbound' ? 'contactTimeline.reachedOut' : 'contactTimeline.heardFrom', {
channel: Object.hasOwn(CHANNEL_LABEL_KEY, c.channel) ? input.t(CHANNEL_LABEL_KEY[c.channel]) : c.channel,
}),
detail: c.summary,
amount: null,
});
Expand All @@ -96,7 +100,7 @@ export function buildContactTimeline(input: {
id: `bday-${now.getUTCFullYear()}`,
kind: 'birthday',
date: ymd(bd),
title: 'Birthday 🎂',
title: `${input.t('family.birthday')} 🎂`,
detail: null,
amount: null,
});
Expand All @@ -120,12 +124,12 @@ export function touchCadenceDays(entries: TimelineEntry[]): number | null {
}

/** Relationship health from the timeline: fresh / due / overdue vs. the cadence. */
export function contactHealth(entries: TimelineEntry[], name: string, now: Date = new Date()): ContactHealth {
export function contactHealth(entries: TimelineEntry[], name: string, t: TimelineTranslator, now: Date = new Date()): ContactHealth {
const touches = entries.filter((e) => e.kind !== 'birthday');
if (touches.length === 0) {
return {
lastTouch: null, daysSince: null, cadenceDays: null, status: 'no_history',
suggestion: `No history with ${name} yet — log a visit or call to start the timeline.`,
suggestion: t('contactTimeline.suggestionNoHistory', { name }),
};
}

Expand All @@ -143,19 +147,27 @@ export function contactHealth(entries: TimelineEntry[], name: string, now: Date

const suggestion =
status === 'fresh'
? `You're in good touch with ${name} — last contact ${daysSince === 0 ? 'today' : `${daysSince}d ago`}.`
? t(daysSince === 0 ? 'contactTimeline.suggestionFreshToday' : 'contactTimeline.suggestionFreshDays', { name, days: daysSince })
: status === 'due'
? `It's been ${daysSince} days — about time for a call or visit with ${name}.`
: `It's been ${daysSince} days since you connected with ${name} — well past your usual rhythm. A quick call goes a long way.`;
? t('contactTimeline.suggestionDue', { name, days: daysSince })
: t('contactTimeline.suggestionOverdue', { name, days: daysSince });

return { lastTouch, daysSince, cadenceDays, status, suggestion };
}

export const INTERACTION_LABEL: Record<InteractionKind, string> = {
visit: 'Visit',
call: 'Call',
message: 'Message',
gift: 'Gift',
favor: 'Favor',
note: 'Note',
export const INTERACTION_LABEL_KEY: Record<InteractionKind, string> = {
visit: 'contactTimeline.visit',
call: 'contacts.call',
message: 'contact.message',
gift: 'relationship.gift',
favor: 'contactTimeline.favor',
note: 'search.kindNote',
};

const CHANNEL_LABEL_KEY: Record<string, string> = {
call: 'contacts.call',
text: 'contact.message',
sms: 'contact.message',
email: 'contacts.email',
school: 'school.school',
};
20 changes: 20 additions & 0 deletions lib/i18n/messages/de-DE.json
Original file line number Diff line number Diff line change
Expand Up @@ -3574,14 +3574,34 @@
"contactTimeline.closeDraft": "Entwurf schließen",
"contactTimeline.deleteEntry": "Eintrag löschen",
"contactTimeline.draftAMessageWithAi": "Nachricht mit KI entwerfen",
"contactTimeline.draftRequestTitle": "Nachricht zur Kontaktaufnahme entwerfen",
"contactTimeline.familyContact": "Familienkontakt",
"contactTimeline.favor": "Gefallen",
"contactTimeline.giftsOptional": "$ (Geschenke – optional)",
"contactTimeline.heardFrom": "Nachricht erhalten · {channel}",
"contactTimeline.lastTouch": "letzter Kontakt",
"contactTimeline.logIt": "Erfassen",
"contactTimeline.logTouch": "Kontakt festhalten",
"contactTimeline.noteOptional": "Notiz — optional",
"contactTimeline.nothingHereYetLogYourFirst": "Noch nichts hier – erfassen Sie oben Ihren ersten Besuch, Anruf oder Ihr erstes Geschenk.",
"contactTimeline.reachedOut": "Kontakt aufgenommen · {channel}",
"contactTimeline.regenerate": "Neu generieren",
"contactTimeline.statusDue": "Zeit für Kontakt",
"contactTimeline.statusFresh": "Gut in Kontakt",
"contactTimeline.statusNoHistory": "Noch kein Verlauf",
"contactTimeline.suggestionDue": "Es sind {days} Tage vergangen — Zeit für einen Anruf oder Besuch bei {name}.",
"contactTimeline.suggestionFreshDays": "Sie sind mit {name} gut in Kontakt — Tage seit dem letzten Kontakt: {days}.",
"contactTimeline.suggestionFreshToday": "Sie sind mit {name} gut in Kontakt — der letzte Kontakt war heute.",
"contactTimeline.suggestionNoHistory": "Noch kein Verlauf mit {name} — halten Sie einen Besuch oder Anruf fest, um die Zeitleiste zu beginnen.",
"contactTimeline.suggestionOverdue": "Ihr letzter Kontakt mit {name} liegt {days} Tage zurück — deutlich länger als üblich. Ein kurzer Anruf kann viel bewirken.",
"contactTimeline.timeline": "Verlauf",
"contactTimeline.toneBrief": "Kurz",
"contactTimeline.tonePlayful": "Locker",
"contactTimeline.toneWarm": "Herzlich",
"contactTimeline.usualRhythm": "üblicher Rhythmus: ca. {days} T.",
"contactTimeline.visit": "Besuch",
"contactTimeline.whatHappenedEGSundayDinner": "Was ist passiert? z. B. Sonntagsessen bei Oma",
"contactTimeline.writing": "Wird verfasst…",
"contactTopic.account": "Hilfe zu Konto und Anmeldung",
"contactTopic.billing": "Abrechnung und Abos",
"contactTopic.bug": "Fehlermeldung – etwas funktioniert nicht",
Expand Down
Loading
Loading