matchday-v3 / components /MemoryPanel.tsx
mzidan000's picture
Publish verified MatchDay v3 source
6d528b3 verified
Raw
History Blame Contribute Delete
16.9 kB
"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>
);
}