| "use client"; |
|
|
| import { Check, ChevronDown, Database, Pencil, Plus, Save, ShieldAlert, TrendingUp, Trash2, X } from "lucide-react"; |
| import { useState } from "react"; |
|
|
| import type { Memory, MemoryPreferenceDefinition, MemoryValue, PreferenceSignal } from "@/lib/types"; |
|
|
| const MEMORY_LABELS: Record<string, string> = { |
| minimum_arrival_buffer_minutes: "Arrival buffer", |
| max_hotel_nightly_cad: "Hotel ceiling", |
| seat_preference: "Seat preference", |
| home_airport: "Home airport", |
| trip_budget_cad: "Trip budget", |
| favorite_teams: "Favorite teams", |
| accessibility_needs: "Accessibility", |
| dietary_needs: "Dietary needs", |
| preferred_currency: "Currency", |
| preferred_host_cities: "Preferred host cities", |
| preferred_countries: "Preferred countries", |
| budget_style: "Budget style", |
| max_hotel_to_stadium_minutes: "Hotel-to-stadium limit", |
| max_walking_minutes: "Walking limit", |
| max_layover_minutes: "Layover limit", |
| preferred_package_style: "Package style", |
| max_vacation_rental_nightly_cad: "Vacation-rental ceiling", |
| max_vacation_rental_to_stadium_minutes: "Rental-to-stadium limit", |
| minimum_rental_bedrooms: "Minimum rental bedrooms", |
| preferred_activity_categories: "Activity interests", |
| max_activity_price_cad: "Activity price ceiling", |
| preferred_vehicle_class: "Preferred vehicle class", |
| preferred_cruise_style: "Cruise style", |
| preferred_traveler_profile: "Traveler profile", |
| }; |
|
|
| function titleCase(value: string): string { |
| return value |
| .replaceAll("_", " ") |
| .replace(/\b\w/g, (character) => character.toUpperCase()); |
| } |
|
|
| function displayValue(memory: Memory): string { |
| if (memory.value.label) return memory.value.label; |
| if (Array.isArray(memory.value.value)) return memory.value.value.join(", "); |
| return `${memory.value.value}${memory.value.unit ? ` ${memory.value.unit}` : ""}`; |
| } |
|
|
| function correctedLabel(key: string, value: MemoryValue["value"]): string | null { |
| if (key === "minimum_arrival_buffer_minutes" && typeof value === "number") { |
| return `${value} minutes before kickoff`; |
| } |
| if (key === "max_hotel_nightly_cad" && typeof value === "number") { |
| return `Hotel ceiling C$${value}/night`; |
| } |
| if (key === "seat_preference" && typeof value === "string") { |
| return `${value.charAt(0).toUpperCase()}${value.slice(1)} seat`; |
| } |
| if (key === "max_vacation_rental_nightly_cad" && typeof value === "number") { |
| return `Rental ceiling C$${value}/night`; |
| } |
| if (key === "max_vacation_rental_to_stadium_minutes" && typeof value === "number") { |
| return `${value} minutes to stadium`; |
| } |
| if (key === "minimum_rental_bedrooms" && typeof value === "number") { |
| return `${value} bedroom${value === 1 ? "" : "s"} minimum`; |
| } |
| if (key === "max_activity_price_cad" && typeof value === "number") { |
| return `Activity ceiling C$${value}`; |
| } |
| if (key === "preferred_activity_categories" && Array.isArray(value)) { |
| return value.map((item) => titleCase(String(item))).join(", "); |
| } |
| if ( |
| ["preferred_vehicle_class", "preferred_cruise_style", "preferred_traveler_profile"].includes(key) |
| && typeof value === "string" |
| ) { |
| return titleCase(value); |
| } |
| return null; |
| } |
|
|
| function sourceLabel(source: string): string { |
| const labels: Record<string, string> = { |
| explicit_user: "Explicit user statement", |
| user_action: "Explicit saved choice", |
| behavior_inferred: "Repeated verified trips", |
| demo_seed: "Demo profile seed", |
| imported: "Imported profile", |
| }; |
| return labels[source] || titleCase(source); |
| } |
|
|
| function scopeLabel(scope: string): string { |
| if (scope === "fan_memory") return "Fan profile"; |
| if (scope === "trip_memory") return "This trip only"; |
| return titleCase(scope); |
| } |
|
|
| function shortDate(value: string): string { |
| const date = new Date(value); |
| if (Number.isNaN(date.getTime())) return "Unknown"; |
| return new Intl.DateTimeFormat("en-CA", { |
| day: "numeric", |
| month: "short", |
| year: "numeric", |
| timeZone: "UTC", |
| }).format(date); |
| } |
|
|
| export function MemoryPanel({ |
| memories, |
| signals, |
| enabled, |
| busy, |
| onToggle, |
| onDelete, |
| onDismissSignal, |
| onUpdate, |
| catalog, |
| onCreate, |
| }: { |
| memories: Memory[]; |
| signals: PreferenceSignal[]; |
| enabled: boolean; |
| busy: boolean; |
| onToggle: (enabled: boolean) => Promise<void>; |
| onDelete: (memoryId: string) => Promise<void>; |
| onDismissSignal: (evidenceId: string) => Promise<void>; |
| onUpdate: (memory: Memory, value: MemoryValue, excerpt: string) => Promise<boolean>; |
| catalog: MemoryPreferenceDefinition[]; |
| onCreate: (key: string, value: MemoryValue["value"]) => Promise<boolean>; |
| }) { |
| const [editing, setEditing] = useState<string | null>(null); |
| const [draft, setDraft] = useState(""); |
| const [adding, setAdding] = useState(false); |
| const [newKey, setNewKey] = useState(""); |
| const [newDraft, setNewDraft] = useState(""); |
| const [newSelections, setNewSelections] = useState<string[]>([]); |
| const selectedDefinition = catalog.find((item) => item.key === newKey) || catalog[0]; |
|
|
| function beginEdit(memory: Memory) { |
| setEditing(memory.id); |
| setDraft(String(memory.value.value)); |
| } |
|
|
| async function save(memory: Memory) { |
| let value: MemoryValue["value"]; |
| if (typeof memory.value.value === "number") { |
| const parsed = Number(draft); |
| if (!Number.isFinite(parsed)) return; |
| value = parsed; |
| } else if (Array.isArray(memory.value.value)) { |
| value = draft.split(",").map((item) => item.trim()).filter(Boolean); |
| if (!value.length) return; |
| } else { |
| value = draft.trim(); |
| if (!value) return; |
| } |
| const saved = await onUpdate( |
| memory, |
| { ...memory.value, value, label: correctedLabel(memory.key, value) }, |
| `I corrected ${MEMORY_LABELS[memory.key] || memory.key} to ${draft.trim()}.`, |
| ); |
| if (saved) setEditing(null); |
| } |
|
|
| function selectDefinition(key: string) { |
| const definition = catalog.find((item) => item.key === key); |
| setNewKey(key); |
| setNewSelections([]); |
| setNewDraft(definition?.value_kind === "select" ? definition.options[0] || "" : ""); |
| } |
|
|
| function beginAdd() { |
| const definition = catalog[0]; |
| if (!definition) return; |
| setAdding(true); |
| selectDefinition(definition.key); |
| } |
|
|
| async function saveNewPreference() { |
| if (!selectedDefinition) return; |
| let value: MemoryValue["value"]; |
| if (selectedDefinition.value_kind === "integer") { |
| const parsed = Number(newDraft); |
| if (!Number.isInteger(parsed)) return; |
| if (selectedDefinition.minimum != null && parsed < selectedDefinition.minimum) return; |
| if (selectedDefinition.maximum != null && parsed > selectedDefinition.maximum) return; |
| value = parsed; |
| } else if (selectedDefinition.value_kind === "list") { |
| value = newDraft.split(",").map((item) => item.trim()).filter(Boolean); |
| if (!value.length) return; |
| } else if (selectedDefinition.value_kind === "multi_select") { |
| value = newSelections; |
| if (!value.length) return; |
| } else { |
| value = newDraft.trim(); |
| if (!value) return; |
| } |
| if (await onCreate(selectedDefinition.key, value)) { |
| setAdding(false); |
| setNewDraft(""); |
| setNewSelections([]); |
| } |
| } |
|
|
| return ( |
| <section className="memory-panel" aria-labelledby="memory-title"> |
| <div className="section-heading"> |
| <div> |
| <span className="eyebrow">Persistent profile</span> |
| <h2 id="memory-title"><Database size={17} /> Fan memory</h2> |
| </div> |
| <label className="switch" title="Enable or disable persistent memory"> |
| <input |
| id="persistent-fan-memory" |
| name="persistent-fan-memory" |
| aria-label="Persistent fan memory" |
| type="checkbox" |
| checked={enabled} |
| disabled={busy} |
| onChange={(event) => void onToggle(event.target.checked)} |
| /> |
| <span aria-hidden="true" /> |
| <b>{enabled ? "On" : "Off"}</b> |
| </label> |
| </div> |
| |
| <div className="memory-toolbar"> |
| <button |
| className="secondary-button memory-add-button" |
| disabled={busy || !enabled || !catalog.length} |
| onClick={beginAdd} |
| > |
| <Plus size={14} aria-hidden="true" /> Add preference |
| </button> |
| </div> |
| |
| {adding && selectedDefinition ? ( |
| <div className="memory-add-form" role="group" aria-label="Add profile preference"> |
| <label> |
| <span>Preference</span> |
| <select |
| aria-label="Preference type" |
| value={selectedDefinition.key} |
| disabled={busy} |
| onChange={(event) => selectDefinition(event.target.value)} |
| > |
| {catalog.map((definition) => ( |
| <option key={definition.key} value={definition.key}>{definition.label}</option> |
| ))} |
| </select> |
| </label> |
| <label> |
| <span>Value</span> |
| {selectedDefinition.value_kind === "select" ? ( |
| <select |
| aria-label="Preference value" |
| value={newDraft} |
| disabled={busy} |
| onChange={(event) => setNewDraft(event.target.value)} |
| > |
| {selectedDefinition.options.map((option) => ( |
| <option key={option} value={option}>{titleCase(option)}</option> |
| ))} |
| </select> |
| ) : selectedDefinition.value_kind === "multi_select" ? ( |
| <select |
| aria-label="Preference values" |
| multiple |
| value={newSelections} |
| disabled={busy} |
| onChange={(event) => setNewSelections( |
| Array.from(event.target.selectedOptions, (option) => option.value), |
| )} |
| > |
| {selectedDefinition.options.map((option) => ( |
| <option key={option} value={option}>{titleCase(option)}</option> |
| ))} |
| </select> |
| ) : ( |
| <input |
| aria-label="Preference value" |
| type={selectedDefinition.value_kind === "integer" ? "number" : "text"} |
| min={selectedDefinition.minimum ?? undefined} |
| max={selectedDefinition.maximum ?? undefined} |
| placeholder={selectedDefinition.placeholder} |
| value={newDraft} |
| disabled={busy} |
| onChange={(event) => setNewDraft(event.target.value)} |
| onKeyDown={(event) => { |
| if (event.key === "Enter") void saveNewPreference(); |
| if (event.key === "Escape") setAdding(false); |
| }} |
| /> |
| )} |
| </label> |
| <p> |
| {selectedDefinition.description} |
| {selectedDefinition.sensitivity === "private" ? " Kept private and excluded from public shares." : ""} |
| </p> |
| <div className="memory-add-actions"> |
| <button className="secondary-button" disabled={busy} onClick={() => void saveNewPreference()}> |
| <Save size={14} aria-hidden="true" /> Save preference |
| </button> |
| <button className="icon-button" disabled={busy} onClick={() => setAdding(false)} aria-label="Cancel adding preference" title="Cancel"> |
| <X size={15} /> |
| </button> |
| </div> |
| </div> |
| ) : null} |
| |
| <div className={`memory-list ${enabled ? "" : "is-disabled"}`}> |
| {!memories.length && !signals.length ? ( |
| <p className="memory-empty-note"> |
| {busy ? "Loading confirmed preferences…" : "No confirmed preferences yet."} |
| </p> |
| ) : null} |
| {memories.map((memory) => ( |
| <div className="memory-row" key={memory.id} data-memory-key={memory.key}> |
| <span className="memory-status" title="Confirmed memory"><Check size={12} /></span> |
| <div className="memory-body"> |
| <span>{MEMORY_LABELS[memory.key] || memory.key.replaceAll("_", " ")}</span> |
| {editing === memory.id ? ( |
| <input |
| id={`memory-${memory.id}-value`} |
| name={`memory-${memory.key}`} |
| aria-label={`Edit ${MEMORY_LABELS[memory.key] || memory.key}`} |
| className="memory-input" |
| value={draft} |
| disabled={busy} |
| onChange={(event) => setDraft(event.target.value)} |
| onKeyDown={(event) => { |
| if (event.key === "Enter") void save(memory); |
| if (event.key === "Escape") setEditing(null); |
| }} |
| autoFocus |
| /> |
| ) : ( |
| <> |
| <strong>{displayValue(memory)}</strong> |
| <details className="memory-disclosure"> |
| <summary aria-label={`Inspect provenance for ${MEMORY_LABELS[memory.key] || memory.key} memory`}> |
| Inspect provenance <ChevronDown size={12} aria-hidden="true" /> |
| </summary> |
| <div className="memory-provenance" data-memory-provenance> |
| <div><span>Source</span><b>{sourceLabel(memory.source_kind)}</b></div> |
| <div><span>Confidence</span><b>{Math.round(memory.confidence * 100)}%</b></div> |
| <div><span>Scope</span><b>{scopeLabel(memory.scope)}</b></div> |
| <div><span>Version</span><b>v{memory.version}</b></div> |
| <div><span>Updated</span><b>{shortDate(memory.updated_at)}</b></div> |
| <div> |
| <span>Expiry</span> |
| <b>{memory.expires_at ? shortDate(memory.expires_at) : "No automatic expiry"}</b> |
| </div> |
| <p> |
| <span>Grounded evidence</span> |
| <q>{memory.source_excerpt}</q> |
| </p> |
| {memory.sensitivity === "private" ? ( |
| <small><ShieldAlert size={11} aria-hidden="true" /> Private — excluded from public shares</small> |
| ) : null} |
| </div> |
| </details> |
| </> |
| )} |
| </div> |
| <div className="icon-actions"> |
| {editing === memory.id ? ( |
| <> |
| <button className="icon-button" disabled={busy} onClick={() => void save(memory)} title="Save memory" aria-label="Save memory"><Save size={15} /></button> |
| <button className="icon-button" disabled={busy} onClick={() => setEditing(null)} title="Cancel edit" aria-label="Cancel edit"><X size={15} /></button> |
| </> |
| ) : ( |
| <> |
| <button className="icon-button" disabled={busy} onClick={() => beginEdit(memory)} title="Correct memory" aria-label="Correct memory"><Pencil size={14} /></button> |
| <button className="icon-button danger" disabled={busy} onClick={() => void onDelete(memory.id)} title="Delete memory" aria-label="Delete memory"><Trash2 size={14} /></button> |
| </> |
| )} |
| </div> |
| </div> |
| ))} |
| {signals.map((signal) => ( |
| <div className="memory-row memory-signal-row" key={signal.id} data-memory-signal={signal.key}> |
| <span |
| className={`memory-status is-${signal.status}`} |
| title={signal.status === "confirmed" ? "Learned preference" : signal.status === "conflicted" ? "Held behind explicit memory" : "Learning signal"} |
| > |
| {signal.status === "conflicted" ? <ShieldAlert size={12} /> : <TrendingUp size={12} />} |
| </span> |
| <div className="memory-body"> |
| <span>{MEMORY_LABELS[signal.key] || signal.key.replaceAll("_", " ")} · learning evidence</span> |
| <strong>{signal.value.label || String(signal.value.value)}</strong> |
| <small> |
| {signal.status === "confirmed" |
| ? `Learned from ${signal.distinct_trip_count} verified trips` |
| : signal.status === "conflicted" |
| ? "Not used because an explicit preference wins" |
| : `${signal.distinct_trip_count} of 2 verified trips · not used yet`} |
| </small> |
| </div> |
| <div className="icon-actions"> |
| <button |
| className="icon-button danger" |
| disabled={busy} |
| onClick={() => void onDismissSignal(signal.id)} |
| title="Dismiss learning signal" |
| aria-label="Dismiss learning signal" |
| > |
| <Trash2 size={14} /> |
| </button> |
| </div> |
| </div> |
| ))} |
| </div> |
| </section> |
| ); |
| } |
|
|