allow overlap (note) tags
This commit is contained in:
@@ -86,11 +86,14 @@ function TagPill({ tag, availableTags }: { tag: string; availableTags: string[]
|
||||
);
|
||||
}
|
||||
|
||||
function detectConflicts(events: TripEvent[]): Set<string> {
|
||||
function detectConflicts(events: TripEvent[], overlapTags: string[] = []): Set<string> {
|
||||
const timed = events.filter((e) => e.start_time);
|
||||
const conflicts = new Set<string>();
|
||||
for (let i = 0; i < timed.length; i++) {
|
||||
for (let j = i + 1; j < timed.length; j++) {
|
||||
const aExempt = timed[i].tags?.some((t) => overlapTags.includes(t));
|
||||
const bExempt = timed[j].tags?.some((t) => overlapTags.includes(t));
|
||||
if (aExempt || bExempt) continue;
|
||||
const aS = toMin(timed[i].start_time!);
|
||||
const aE = timed[i].end_time ? toMin(timed[i].end_time!) : aS + 60;
|
||||
const bS = toMin(timed[j].start_time!);
|
||||
@@ -365,6 +368,7 @@ function DayTimeline({
|
||||
onEdit,
|
||||
driveSegments,
|
||||
availableTags,
|
||||
overlapTags,
|
||||
}: {
|
||||
events: TripEvent[];
|
||||
isAdmin: boolean;
|
||||
@@ -372,12 +376,16 @@ function DayTimeline({
|
||||
onEdit: (event: TripEvent) => void;
|
||||
driveSegments: { fromId: string; toId: string; text: string }[];
|
||||
availableTags: string[];
|
||||
overlapTags: string[];
|
||||
}) {
|
||||
const timed = [...events.filter((e) => e.start_time)].sort(
|
||||
(a, b) => toMin(a.start_time!) - toMin(b.start_time!)
|
||||
);
|
||||
const untimed = events.filter((e) => !e.start_time);
|
||||
const conflicts = detectConflicts(events);
|
||||
const isNote = (e: TripEvent) => !!e.tags?.some((t) => overlapTags.includes(t));
|
||||
const noteEvents = timed.filter((e) => isNote(e));
|
||||
const regularEvents = timed.filter((e) => !isNote(e));
|
||||
const conflicts = detectConflicts(events, overlapTags);
|
||||
|
||||
if (timed.length === 0 && untimed.length === 0) {
|
||||
return (
|
||||
@@ -423,8 +431,45 @@ function DayTimeline({
|
||||
</div>
|
||||
))}
|
||||
|
||||
{/* Event blocks */}
|
||||
{timed.map((e) => {
|
||||
{/* Note events — overlap-allowed, rendered behind as subtle bands */}
|
||||
{noteEvents.map((e) => {
|
||||
const startMin = toMin(e.start_time!);
|
||||
const endMin = e.end_time ? toMin(e.end_time) : startMin;
|
||||
const top = (startMin - rangeStart) * PX_PER_MIN;
|
||||
const height = Math.max(1, (endMin - startMin) * PX_PER_MIN);
|
||||
|
||||
return (
|
||||
<div key={e.event_id} className="group">
|
||||
{/* Shaded band (only if duration given) */}
|
||||
{e.end_time && (
|
||||
<div
|
||||
style={{ top, height, left: 60, right: 0 }}
|
||||
className="absolute bg-gray-800/30 border-l-2 border-gray-600/30 z-0 pointer-events-none"
|
||||
/>
|
||||
)}
|
||||
{/* Dashed marker line + label */}
|
||||
<div
|
||||
style={{ top, left: 60, right: 0 }}
|
||||
className="absolute z-10 flex items-center gap-2"
|
||||
>
|
||||
<div className="flex-1 border-t border-dashed border-gray-600/40" />
|
||||
<span className="text-gray-500 text-[10px] font-mono shrink-0 pr-1">
|
||||
{fmtTime(e.start_time)}
|
||||
</span>
|
||||
<span className="text-gray-500 text-[10px] truncate max-w-[120px] shrink-0">{e.title}</span>
|
||||
{isAdmin && (
|
||||
<div className="flex gap-1 opacity-0 group-hover:opacity-100 transition-opacity shrink-0">
|
||||
<button onClick={() => onEdit(e)} className="text-gray-600 hover:text-indigo-400 text-[10px]">Edit</button>
|
||||
<button onClick={() => onDelete(e.event_id)} className="text-gray-600 hover:text-red-400 text-xs leading-none">×</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
{/* Regular event blocks */}
|
||||
{regularEvents.map((e) => {
|
||||
const startMin = toMin(e.start_time!);
|
||||
const endMin = e.end_time ? toMin(e.end_time) : startMin + 60;
|
||||
const top = (startMin - rangeStart) * PX_PER_MIN;
|
||||
@@ -436,7 +481,7 @@ function DayTimeline({
|
||||
<div key={e.event_id}>
|
||||
<div
|
||||
style={{ top, height, left: 60, right: 0 }}
|
||||
className={`absolute rounded-lg px-3 py-2 overflow-hidden group transition-colors ${
|
||||
className={`absolute rounded-lg px-3 py-2 overflow-hidden group transition-colors z-20 ${
|
||||
isConflict
|
||||
? "bg-red-950/70 border border-red-700/70"
|
||||
: "bg-indigo-950/60 border border-indigo-800/50 hover:border-indigo-600/70"
|
||||
@@ -495,11 +540,10 @@ function DayTimeline({
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/* Drive time badge below this event, if present */}
|
||||
{drive && (
|
||||
<div
|
||||
style={{ top: top + height + 2, left: 60 }}
|
||||
className="absolute text-xs text-gray-600 font-mono flex items-center gap-1"
|
||||
className="absolute text-xs text-gray-600 font-mono flex items-center gap-1 z-20"
|
||||
>
|
||||
<span className="text-gray-700">↓</span> {drive} drive
|
||||
</div>
|
||||
@@ -965,17 +1009,27 @@ export default function TripDetailPage() {
|
||||
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);
|
||||
const available = [...(trip.available_tags ?? []), tag];
|
||||
const overlap = trip.overlap_tags ?? [];
|
||||
await c2api.updateTripTags(trip.trip_id, available, overlap);
|
||||
setTrip((prev) => prev ? { ...prev, available_tags: available } : 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);
|
||||
const available = (trip.available_tags ?? []).filter((t) => t !== tag);
|
||||
const overlap = (trip.overlap_tags ?? []).filter((t) => t !== tag);
|
||||
await c2api.updateTripTags(trip.trip_id, available, overlap);
|
||||
setTrip((prev) => prev ? { ...prev, available_tags: available, overlap_tags: overlap } : prev);
|
||||
}
|
||||
|
||||
async function handleToggleOverlap(tag: string) {
|
||||
if (!trip) return;
|
||||
const current = trip.overlap_tags ?? [];
|
||||
const overlap = current.includes(tag) ? current.filter((t) => t !== tag) : [...current, tag];
|
||||
await c2api.updateTripTags(trip.trip_id, trip.available_tags ?? [], overlap);
|
||||
setTrip((prev) => prev ? { ...prev, overlap_tags: overlap } : prev);
|
||||
}
|
||||
|
||||
if (loading) return <p className="text-gray-500 text-sm font-mono">Loading…</p>;
|
||||
@@ -985,7 +1039,7 @@ export default function TripDetailPage() {
|
||||
const attendees = Object.values(trip.attendees ?? {});
|
||||
const dayEvents = trip.events.filter((e) => e.date === selectedDay);
|
||||
const hasConflict = (day: string) =>
|
||||
detectConflicts(trip.events.filter((e) => e.date === day)).size > 0;
|
||||
detectConflicts(trip.events.filter((e) => e.date === day), trip.overlap_tags ?? []).size > 0;
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
@@ -1043,14 +1097,26 @@ export default function TripDetailPage() {
|
||||
{/* 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>
|
||||
))}
|
||||
{(trip.available_tags ?? []).map((tag) => {
|
||||
const isOverlap = (trip.overlap_tags ?? []).includes(tag);
|
||||
return (
|
||||
<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={() => handleToggleOverlap(tag)}
|
||||
title={isOverlap ? "Allows overlap (click to disable)" : "Click to allow overlap"}
|
||||
className={`leading-none transition-colors ${isOverlap ? "opacity-100" : "opacity-30 hover:opacity-70"}`}
|
||||
>
|
||||
≋
|
||||
</button>
|
||||
<button onClick={() => handleRemoveTag(tag)} className="hover:text-white transition-colors leading-none opacity-60 hover:opacity-100">×</button>
|
||||
</>
|
||||
)}
|
||||
</span>
|
||||
);
|
||||
})}
|
||||
{isAdmin && (
|
||||
<div className="flex items-center gap-1">
|
||||
<input
|
||||
@@ -1114,6 +1180,7 @@ export default function TripDetailPage() {
|
||||
onEdit={setEditEvent}
|
||||
driveSegments={driveTimes[selectedDay] ?? []}
|
||||
availableTags={trip.available_tags ?? []}
|
||||
overlapTags={trip.overlap_tags ?? []}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user