Trip-level tags: admins configure available tags in the trip header (inline add/remove pills). The AI can also create new tags via the add_tag tool.
Event tags: selectable in the Add Event modal, shown as colored pills on event cards in the timeline, and on AI suggestion cards.
AI integration: sees available tags in its system prompt, applies them when proposing events, can create new ones with add_tag.
Discord: tags shown as inline code blocks under each event in /trip view.
Colors: auto-assigned from an 8-color palette by tag index, consistent everywhere.
This commit is contained in:
Logan
2026-06-21 15:00:37 -04:00
parent a0fdf2486e
commit 3fb3bca034
6 changed files with 184 additions and 3 deletions
+120
View File
@@ -58,6 +58,34 @@ function dateRange(start: string, end: string): string[] {
return dates;
}
// ---------------------------------------------------------------------------
// Tag helpers
// ---------------------------------------------------------------------------
const TAG_PALETTE = [
"bg-violet-900/60 text-violet-300 border-violet-700/50",
"bg-sky-900/60 text-sky-300 border-sky-700/50",
"bg-emerald-900/60 text-emerald-300 border-emerald-700/50",
"bg-amber-900/60 text-amber-300 border-amber-700/50",
"bg-rose-900/60 text-rose-300 border-rose-700/50",
"bg-fuchsia-900/60 text-fuchsia-300 border-fuchsia-700/50",
"bg-cyan-900/60 text-cyan-300 border-cyan-700/50",
"bg-orange-900/60 text-orange-300 border-orange-700/50",
];
function tagColor(tag: string, availableTags: string[]): string {
const idx = availableTags.indexOf(tag);
return TAG_PALETTE[(idx >= 0 ? idx : 0) % TAG_PALETTE.length];
}
function TagPill({ tag, availableTags }: { tag: string; availableTags: string[] }) {
return (
<span className={`inline-block text-[10px] font-medium rounded-full px-2 py-0.5 border ${tagColor(tag, availableTags)}`}>
{tag}
</span>
);
}
function detectConflicts(events: TripEvent[]): Set<string> {
const timed = events.filter((e) => e.start_time);
const conflicts = new Set<string>();
@@ -158,6 +186,7 @@ function AddEventModal({
onAdd: (body: object) => Promise<void>;
prefill?: Partial<TripEvent>;
}) {
const availableTags = trip.available_tags ?? [];
const [title, setTitle] = useState(prefill?.title ?? "");
const [date, setDate] = useState(prefill?.date ?? trip.start_date);
const [startTime, setStartTime] = useState(prefill?.start_time ?? "");
@@ -166,6 +195,7 @@ function AddEventModal({
const [mapsLink, setMapsLink] = useState(prefill?.maps_link ?? "");
const [placeId, setPlaceId] = useState(prefill?.place_id ?? "");
const [notes, setNotes] = useState(prefill?.notes ?? "");
const [tags, setTags] = useState<string[]>(prefill?.tags ?? []);
const [saving, setSaving] = useState(false);
const [error, setError] = useState<string | null>(null);
@@ -190,6 +220,7 @@ function AddEventModal({
maps_link: mapsLink || null,
place_id: placeId || null,
notes: notes || null,
tags,
});
onClose();
} catch {
@@ -277,6 +308,29 @@ function AddEventModal({
/>
</div>
{availableTags.length > 0 && (
<div>
<label className="text-xs text-gray-400 block mb-1">Tags</label>
<div className="flex flex-wrap gap-1.5">
{availableTags.map((tag) => {
const active = tags.includes(tag);
return (
<button
key={tag}
type="button"
onClick={() => setTags((prev) => active ? prev.filter((t) => t !== tag) : [...prev, tag])}
className={`text-xs rounded-full px-2.5 py-1 border transition-colors ${
active ? tagColor(tag, availableTags) : "bg-gray-800 text-gray-500 border-gray-700 hover:border-gray-600 hover:text-gray-400"
}`}
>
{tag}
</button>
);
})}
</div>
</div>
)}
{error && <p className="text-red-400 text-xs">{error}</p>}
<div className="flex gap-3 justify-end pt-1">
@@ -306,11 +360,13 @@ function DayTimeline({
isAdmin,
onDelete,
driveSegments,
availableTags,
}: {
events: TripEvent[];
isAdmin: boolean;
onDelete: (id: string) => void;
driveSegments: { fromId: string; toId: string; text: string }[];
availableTags: string[];
}) {
const timed = [...events.filter((e) => e.start_time)].sort(
(a, b) => toMin(a.start_time!) - toMin(b.start_time!)
@@ -397,6 +453,11 @@ function DayTimeline({
{height >= 60 && e.notes && (
<p className="text-gray-600 text-xs mt-0.5 italic truncate">{e.notes}</p>
)}
{e.tags?.length > 0 && (
<div className="flex flex-wrap gap-1 mt-1">
{e.tags.map((t) => <TagPill key={t} tag={t} availableTags={availableTags} />)}
</div>
)}
</div>
<div className="flex items-center gap-1 shrink-0">
{e.maps_link && (
@@ -451,6 +512,11 @@ function DayTimeline({
<p className="text-gray-500 text-xs mt-0.5">{e.location}</p>
)}
{e.notes && <p className="text-gray-600 text-xs italic mt-0.5">{e.notes}</p>}
{e.tags?.length > 0 && (
<div className="flex flex-wrap gap-1 mt-1">
{e.tags.map((t) => <TagPill key={t} tag={t} availableTags={availableTags} />)}
</div>
)}
</div>
<div className="flex items-center gap-2 shrink-0">
{e.maps_link && (
@@ -493,6 +559,7 @@ interface SuggestionCard {
location?: string;
maps_link?: string;
notes?: string;
tags?: string[];
dismissed?: boolean;
added?: boolean;
}
@@ -567,6 +634,7 @@ function AssistantPanel({
location: s.location ?? null,
maps_link: s.maps_link ?? null,
notes: s.notes ?? null,
tags: s.tags ?? [],
});
onAddEvent(event);
setMessages((prev) =>
@@ -672,6 +740,11 @@ function AssistantPanel({
)}
{s.location && <p className="truncate">{s.location}</p>}
{s.notes && <p className="text-gray-500 italic">{s.notes}</p>}
{s.tags && s.tags.length > 0 && (
<div className="flex flex-wrap gap-1 pt-0.5">
{s.tags.map((t) => <TagPill key={t} tag={t} availableTags={trip.available_tags ?? []} />)}
</div>
)}
</div>
{s.maps_link && (
<a href={s.maps_link} target="_blank" rel="noopener noreferrer"
@@ -755,6 +828,7 @@ export default function TripDetailPage() {
const [selectedDay, setSelectedDay] = useState<string>("");
const [showAdd, setShowAdd] = useState(false);
const [driveTimes, setDriveTimes] = useState<Record<string, { fromId: string; toId: string; text: string }[]>>({});
const [tagInput, setTagInput] = useState("");
const load = useCallback(async () => {
try {
@@ -836,6 +910,25 @@ export default function TripDetailPage() {
);
return { ...prev, events };
});
// Refresh trip to pick up any new tags the AI may have created
c2api.getTrip(id).then((data) => setTrip((prev) => prev ? { ...prev, available_tags: (data as FullTrip).available_tags } : prev)).catch(() => {});
}
async function handleAddTag() {
if (!trip || !tagInput.trim()) return;
const tag = tagInput.trim();
if (trip.available_tags?.includes(tag)) { setTagInput(""); return; }
const updated = [...(trip.available_tags ?? []), tag];
await c2api.updateTripTags(trip.trip_id, updated);
setTrip((prev) => prev ? { ...prev, available_tags: updated } : prev);
setTagInput("");
}
async function handleRemoveTag(tag: string) {
if (!trip) return;
const updated = (trip.available_tags ?? []).filter((t) => t !== tag);
await c2api.updateTripTags(trip.trip_id, updated);
setTrip((prev) => prev ? { ...prev, available_tags: updated } : prev);
}
if (loading) return <p className="text-gray-500 text-sm font-mono">Loading</p>;
@@ -899,6 +992,32 @@ export default function TripDetailPage() {
)}
</div>
</div>
{/* Tag manager */}
{(isAdmin || (trip.available_tags ?? []).length > 0) && (
<div className="flex flex-wrap items-center gap-2">
{(trip.available_tags ?? []).map((tag) => (
<span key={tag} className={`inline-flex items-center gap-1 text-xs rounded-full px-2.5 py-0.5 border ${tagColor(tag, trip.available_tags ?? [])}`}>
{tag}
{isAdmin && (
<button onClick={() => handleRemoveTag(tag)} className="hover:text-white transition-colors leading-none">×</button>
)}
</span>
))}
{isAdmin && (
<div className="flex items-center gap-1">
<input
value={tagInput}
onChange={(e) => setTagInput(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && (e.preventDefault(), handleAddTag())}
placeholder="Add tag…"
className="bg-transparent border border-gray-700 rounded-full px-2.5 py-0.5 text-xs text-gray-400 placeholder-gray-600 focus:outline-none focus:border-gray-500 w-24"
/>
<button onClick={handleAddTag} className="text-xs text-gray-500 hover:text-gray-300 transition-colors">+</button>
</div>
)}
</div>
)}
</div>
{/* Two-column layout */}
@@ -946,6 +1065,7 @@ export default function TripDetailPage() {
isAdmin={isAdmin}
onDelete={handleDeleteEvent}
driveSegments={driveTimes[selectedDay] ?? []}
availableTags={trip.available_tags ?? []}
/>
</div>
</div>