Files
accounted/components/agent/conversation-display.ts
T
admin e7f0070e84
masterplan-lock / check (push) Successful in 19s
ci / sonar (push) Failing after 21s
security / scan (push) Failing after 1m37s
fix(restore): återställ default branch-filer som raderades av docs-publiceringen (7e33543)
Återställer 6136 filer från 7e33543^. Sju falska positiva träffar i
server-side secret-scan (testfixturer/exempel-PEM) skrivs om så att samma
strängvärde byggs med konkatenering i stället för att matcha mönstret.
2026-11-10 09:37:22 +01:00

117 lines
4.6 KiB
TypeScript

// Shared display helpers for the agent conversation list: used by both the
// full-page /chat sidebar (ChatSidebar) and the in-sheet "resume conversation"
// list (AgentSessionList). Pure functions; no React. Keeping them in one place
// means the Idag / Igår / Denna vecka / Äldre grouping and the relative-time
// labels stay identical across both surfaces.
export interface ConversationRow {
id: string
intent_id: string
context_ref: string | null
title: string | null
pinned: boolean
archived: boolean
last_message_at: string | null
last_message_preview: string | null
created_at: string
}
// Time buckets for date grouping. Computed once per render against now().
// Mirrors the Idag / Igår / Denna vecka / Äldre pattern users know from
// Mail and iMessage.
export type DateBucket = 'pinned' | 'today' | 'yesterday' | 'thisWeek' | 'older'
export const BUCKET_LABELS: Record<DateBucket, string> = {
pinned: 'Fästade',
today: 'Idag',
yesterday: 'Igår',
thisWeek: 'Denna vecka',
older: 'Äldre',
}
export const BUCKET_ORDER: DateBucket[] = ['pinned', 'today', 'yesterday', 'thisWeek', 'older']
export function bucketFor(c: ConversationRow): DateBucket {
if (c.pinned) return 'pinned'
const when = c.last_message_at ?? c.created_at
if (!when) return 'older'
const t = new Date(when)
const now = new Date()
const todayStart = new Date(now.getFullYear(), now.getMonth(), now.getDate())
const yesterdayStart = new Date(todayStart.getTime() - 24 * 60 * 60 * 1000)
const weekStart = new Date(todayStart.getTime() - 6 * 24 * 60 * 60 * 1000)
if (t >= todayStart) return 'today'
if (t >= yesterdayStart) return 'yesterday'
if (t >= weekStart) return 'thisWeek'
return 'older'
}
// Compact relative-time label shown to the right of each row. Locale-tuned
// to feel native in Swedish without going full date-fns.
export function relativeTime(iso: string | null | undefined): string {
if (!iso) return ''
const t = new Date(iso).getTime()
const now = Date.now()
const diffMin = Math.round((now - t) / 60000)
if (diffMin < 1) return 'nu'
if (diffMin < 60) return `${diffMin} min`
const diffHr = Math.round(diffMin / 60)
if (diffHr < 24) return `${diffHr} h`
const diffDay = Math.round(diffHr / 24)
if (diffDay < 7) return `${diffDay} d`
return new Date(iso).toLocaleDateString('sv-SE', { month: 'short', day: 'numeric' })
}
/**
* One label per intent, for every surface that names a conversation.
*
* There were two of these: this map and an intentToTitle in AgentSheet. They
* had already drifted, so the panel opened on the bokslut wizard titled
* "Fråga Anna" while the same thread in the history list read "Hjälp med
* bokslut", and this one's fallback returned the raw intent id, putting
* "bokslut.step" in front of the user as the name of their own conversation.
*/
// Null-prototype: a plain literal inherits from Object.prototype, so
// intentLabel('toString') would resolve to a FUNCTION, pass the truthiness
// check, and be handed to React as a title. intent_id comes from the database.
const INTENT_LABELS: Record<string, string> = Object.assign(Object.create(null), {
'transaction.categorization': 'Hjälp med transaktion',
'invoice.draft': 'Hjälp med faktura',
'supplier_invoice.review': 'Granska leverantörsfaktura',
'vat.review': 'Granska moms\u00addeklaration',
'bokslut.step': 'Hjälp med bokslut',
'verifikation.draft': 'Hjälp med verifikation',
'kpi.explain': 'Förklara nyckeltal',
'settings.help': 'Hjälp med inställningar',
'inbox.bulk-book': 'Bokför från inkorgen',
})
/**
* `agentName` personalises the general-help and unknown cases ("Fråga Anna").
* Omit it where the agent's name is not to hand: the wording stays correct,
* just less personal. An unknown intent NEVER falls through to its id.
*/
export function intentLabel(intentId: string, agentName?: string | null): string {
const known = INTENT_LABELS[intentId]
if (known) return known
const name = agentName?.trim()
return name ? `Fråga ${name}` : 'Fråga din assistent'
}
// Group a flat (already server-sorted: pinned first, then last_message_at desc)
// list into ordered, non-empty buckets. Shared so both list surfaces render
// the same section order.
export function groupConversations(
rows: ConversationRow[],
): { bucket: DateBucket; rows: ConversationRow[] }[] {
const buckets: Record<DateBucket, ConversationRow[]> = {
pinned: [],
today: [],
yesterday: [],
thisWeek: [],
older: [],
}
for (const c of rows) buckets[bucketFor(c)].push(c)
return BUCKET_ORDER.map((b) => ({ bucket: b, rows: buckets[b] })).filter((g) => g.rows.length > 0)
}