add tags
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:
@@ -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>
|
||||
|
||||
@@ -142,6 +142,8 @@ export const c2api = {
|
||||
request<import("@/lib/types").TripRecord>("/trips", { method: "POST", body: JSON.stringify(body) }),
|
||||
deleteTrip: (id: string) =>
|
||||
request(`/trips/${id}`, { method: "DELETE" }),
|
||||
updateTripTags: (id: string, available_tags: string[]) =>
|
||||
request<{ available_tags: string[] }>(`/trips/${id}/tags`, { method: "PUT", body: JSON.stringify({ available_tags }) }),
|
||||
createTripEvent: (tripId: string, body: object) =>
|
||||
request<import("@/lib/types").TripEvent>(`/trips/${tripId}/events`, { method: "POST", body: JSON.stringify(body) }),
|
||||
deleteTripEvent: (tripId: string, eventId: string) =>
|
||||
|
||||
@@ -110,6 +110,7 @@ export interface TripEvent {
|
||||
maps_link: string | null;
|
||||
place_id: string | null;
|
||||
notes: string | null;
|
||||
tags: string[];
|
||||
attendees: Record<string, string>;
|
||||
created_at: string;
|
||||
}
|
||||
@@ -132,6 +133,7 @@ export interface TripRecord {
|
||||
start_date: string;
|
||||
end_date: string;
|
||||
attendees: Record<string, string>;
|
||||
available_tags: string[];
|
||||
created_at: string;
|
||||
events?: TripEvent[];
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user