allow overlap (note) tags
This commit is contained in:
@@ -147,6 +147,7 @@ class TripCreate(BaseModel):
|
|||||||
start_date: str # YYYY-MM-DD
|
start_date: str # YYYY-MM-DD
|
||||||
end_date: str # YYYY-MM-DD
|
end_date: str # YYYY-MM-DD
|
||||||
available_tags: List[str] = [] # tag labels configured for this trip
|
available_tags: List[str] = [] # tag labels configured for this trip
|
||||||
|
overlap_tags: List[str] = [] # subset of available_tags that allow time overlap
|
||||||
|
|
||||||
|
|
||||||
class TripEventCreate(BaseModel):
|
class TripEventCreate(BaseModel):
|
||||||
|
|||||||
@@ -208,6 +208,7 @@ async def create_trip(body: TripCreate):
|
|||||||
"end_date": body.end_date,
|
"end_date": body.end_date,
|
||||||
"attendees": {}, # {discord_user_id: discord_username}
|
"attendees": {}, # {discord_user_id: discord_username}
|
||||||
"available_tags": body.available_tags,
|
"available_tags": body.available_tags,
|
||||||
|
"overlap_tags": body.overlap_tags,
|
||||||
"created_at": now,
|
"created_at": now,
|
||||||
}
|
}
|
||||||
await fstore.doc_set("trips", trip_id, doc, merge=False)
|
await fstore.doc_set("trips", trip_id, doc, merge=False)
|
||||||
@@ -226,13 +227,14 @@ async def get_trip(trip_id: str):
|
|||||||
|
|
||||||
@router.put("/{trip_id}/tags")
|
@router.put("/{trip_id}/tags")
|
||||||
async def update_trip_tags(trip_id: str, body: dict):
|
async def update_trip_tags(trip_id: str, body: dict):
|
||||||
"""Replace the trip's available tag list."""
|
"""Replace the trip's available tag list and overlap-allowed tag list."""
|
||||||
trip = await fstore.doc_get("trips", trip_id)
|
trip = await fstore.doc_get("trips", trip_id)
|
||||||
if not trip:
|
if not trip:
|
||||||
raise HTTPException(404, f"Trip '{trip_id}' not found.")
|
raise HTTPException(404, f"Trip '{trip_id}' not found.")
|
||||||
tags = [str(t) for t in body.get("available_tags", []) if t]
|
tags = [str(t) for t in body.get("available_tags", []) if t]
|
||||||
await fstore.doc_update("trips", trip_id, {"available_tags": tags})
|
overlap = [str(t) for t in body.get("overlap_tags", []) if t and t in tags]
|
||||||
return {"available_tags": tags}
|
await fstore.doc_update("trips", trip_id, {"available_tags": tags, "overlap_tags": overlap})
|
||||||
|
return {"available_tags": tags, "overlap_tags": overlap}
|
||||||
|
|
||||||
|
|
||||||
@router.delete("/{trip_id}")
|
@router.delete("/{trip_id}")
|
||||||
|
|||||||
@@ -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 timed = events.filter((e) => e.start_time);
|
||||||
const conflicts = new Set<string>();
|
const conflicts = new Set<string>();
|
||||||
for (let i = 0; i < timed.length; i++) {
|
for (let i = 0; i < timed.length; i++) {
|
||||||
for (let j = i + 1; j < timed.length; j++) {
|
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 aS = toMin(timed[i].start_time!);
|
||||||
const aE = timed[i].end_time ? toMin(timed[i].end_time!) : aS + 60;
|
const aE = timed[i].end_time ? toMin(timed[i].end_time!) : aS + 60;
|
||||||
const bS = toMin(timed[j].start_time!);
|
const bS = toMin(timed[j].start_time!);
|
||||||
@@ -365,6 +368,7 @@ function DayTimeline({
|
|||||||
onEdit,
|
onEdit,
|
||||||
driveSegments,
|
driveSegments,
|
||||||
availableTags,
|
availableTags,
|
||||||
|
overlapTags,
|
||||||
}: {
|
}: {
|
||||||
events: TripEvent[];
|
events: TripEvent[];
|
||||||
isAdmin: boolean;
|
isAdmin: boolean;
|
||||||
@@ -372,12 +376,16 @@ function DayTimeline({
|
|||||||
onEdit: (event: TripEvent) => void;
|
onEdit: (event: TripEvent) => void;
|
||||||
driveSegments: { fromId: string; toId: string; text: string }[];
|
driveSegments: { fromId: string; toId: string; text: string }[];
|
||||||
availableTags: string[];
|
availableTags: string[];
|
||||||
|
overlapTags: string[];
|
||||||
}) {
|
}) {
|
||||||
const timed = [...events.filter((e) => e.start_time)].sort(
|
const timed = [...events.filter((e) => e.start_time)].sort(
|
||||||
(a, b) => toMin(a.start_time!) - toMin(b.start_time!)
|
(a, b) => toMin(a.start_time!) - toMin(b.start_time!)
|
||||||
);
|
);
|
||||||
const untimed = events.filter((e) => !e.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) {
|
if (timed.length === 0 && untimed.length === 0) {
|
||||||
return (
|
return (
|
||||||
@@ -423,8 +431,45 @@ function DayTimeline({
|
|||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
|
|
||||||
{/* Event blocks */}
|
{/* Note events — overlap-allowed, rendered behind as subtle bands */}
|
||||||
{timed.map((e) => {
|
{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 startMin = toMin(e.start_time!);
|
||||||
const endMin = e.end_time ? toMin(e.end_time) : startMin + 60;
|
const endMin = e.end_time ? toMin(e.end_time) : startMin + 60;
|
||||||
const top = (startMin - rangeStart) * PX_PER_MIN;
|
const top = (startMin - rangeStart) * PX_PER_MIN;
|
||||||
@@ -436,7 +481,7 @@ function DayTimeline({
|
|||||||
<div key={e.event_id}>
|
<div key={e.event_id}>
|
||||||
<div
|
<div
|
||||||
style={{ top, height, left: 60, right: 0 }}
|
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
|
isConflict
|
||||||
? "bg-red-950/70 border border-red-700/70"
|
? "bg-red-950/70 border border-red-700/70"
|
||||||
: "bg-indigo-950/60 border border-indigo-800/50 hover:border-indigo-600/70"
|
: "bg-indigo-950/60 border border-indigo-800/50 hover:border-indigo-600/70"
|
||||||
@@ -495,11 +540,10 @@ function DayTimeline({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{/* Drive time badge below this event, if present */}
|
|
||||||
{drive && (
|
{drive && (
|
||||||
<div
|
<div
|
||||||
style={{ top: top + height + 2, left: 60 }}
|
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
|
<span className="text-gray-700">↓</span> {drive} drive
|
||||||
</div>
|
</div>
|
||||||
@@ -965,17 +1009,27 @@ export default function TripDetailPage() {
|
|||||||
if (!trip || !tagInput.trim()) return;
|
if (!trip || !tagInput.trim()) return;
|
||||||
const tag = tagInput.trim();
|
const tag = tagInput.trim();
|
||||||
if (trip.available_tags?.includes(tag)) { setTagInput(""); return; }
|
if (trip.available_tags?.includes(tag)) { setTagInput(""); return; }
|
||||||
const updated = [...(trip.available_tags ?? []), tag];
|
const available = [...(trip.available_tags ?? []), tag];
|
||||||
await c2api.updateTripTags(trip.trip_id, updated);
|
const overlap = trip.overlap_tags ?? [];
|
||||||
setTrip((prev) => prev ? { ...prev, available_tags: updated } : prev);
|
await c2api.updateTripTags(trip.trip_id, available, overlap);
|
||||||
|
setTrip((prev) => prev ? { ...prev, available_tags: available } : prev);
|
||||||
setTagInput("");
|
setTagInput("");
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleRemoveTag(tag: string) {
|
async function handleRemoveTag(tag: string) {
|
||||||
if (!trip) return;
|
if (!trip) return;
|
||||||
const updated = (trip.available_tags ?? []).filter((t) => t !== tag);
|
const available = (trip.available_tags ?? []).filter((t) => t !== tag);
|
||||||
await c2api.updateTripTags(trip.trip_id, updated);
|
const overlap = (trip.overlap_tags ?? []).filter((t) => t !== tag);
|
||||||
setTrip((prev) => prev ? { ...prev, available_tags: updated } : prev);
|
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>;
|
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 attendees = Object.values(trip.attendees ?? {});
|
||||||
const dayEvents = trip.events.filter((e) => e.date === selectedDay);
|
const dayEvents = trip.events.filter((e) => e.date === selectedDay);
|
||||||
const hasConflict = (day: string) =>
|
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 (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
@@ -1043,14 +1097,26 @@ export default function TripDetailPage() {
|
|||||||
{/* Tag manager */}
|
{/* Tag manager */}
|
||||||
{(isAdmin || (trip.available_tags ?? []).length > 0) && (
|
{(isAdmin || (trip.available_tags ?? []).length > 0) && (
|
||||||
<div className="flex flex-wrap items-center gap-2">
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
{(trip.available_tags ?? []).map((tag) => (
|
{(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 ?? [])}`}>
|
const isOverlap = (trip.overlap_tags ?? []).includes(tag);
|
||||||
{tag}
|
return (
|
||||||
{isAdmin && (
|
<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 ?? [])}`}>
|
||||||
<button onClick={() => handleRemoveTag(tag)} className="hover:text-white transition-colors leading-none">×</button>
|
{tag}
|
||||||
)}
|
{isAdmin && (
|
||||||
</span>
|
<>
|
||||||
))}
|
<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 && (
|
{isAdmin && (
|
||||||
<div className="flex items-center gap-1">
|
<div className="flex items-center gap-1">
|
||||||
<input
|
<input
|
||||||
@@ -1114,6 +1180,7 @@ export default function TripDetailPage() {
|
|||||||
onEdit={setEditEvent}
|
onEdit={setEditEvent}
|
||||||
driveSegments={driveTimes[selectedDay] ?? []}
|
driveSegments={driveTimes[selectedDay] ?? []}
|
||||||
availableTags={trip.available_tags ?? []}
|
availableTags={trip.available_tags ?? []}
|
||||||
|
overlapTags={trip.overlap_tags ?? []}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -142,8 +142,8 @@ export const c2api = {
|
|||||||
request<import("@/lib/types").TripRecord>("/trips", { method: "POST", body: JSON.stringify(body) }),
|
request<import("@/lib/types").TripRecord>("/trips", { method: "POST", body: JSON.stringify(body) }),
|
||||||
deleteTrip: (id: string) =>
|
deleteTrip: (id: string) =>
|
||||||
request(`/trips/${id}`, { method: "DELETE" }),
|
request(`/trips/${id}`, { method: "DELETE" }),
|
||||||
updateTripTags: (id: string, available_tags: string[]) =>
|
updateTripTags: (id: string, available_tags: string[], overlap_tags: string[]) =>
|
||||||
request<{ available_tags: string[] }>(`/trips/${id}/tags`, { method: "PUT", body: JSON.stringify({ available_tags }) }),
|
request<{ available_tags: string[]; overlap_tags: string[] }>(`/trips/${id}/tags`, { method: "PUT", body: JSON.stringify({ available_tags, overlap_tags }) }),
|
||||||
createTripEvent: (tripId: string, body: object) =>
|
createTripEvent: (tripId: string, body: object) =>
|
||||||
request<import("@/lib/types").TripEvent>(`/trips/${tripId}/events`, { method: "POST", body: JSON.stringify(body) }),
|
request<import("@/lib/types").TripEvent>(`/trips/${tripId}/events`, { method: "POST", body: JSON.stringify(body) }),
|
||||||
updateTripEvent: (tripId: string, eventId: string, body: object) =>
|
updateTripEvent: (tripId: string, eventId: string, body: object) =>
|
||||||
|
|||||||
@@ -134,6 +134,7 @@ export interface TripRecord {
|
|||||||
end_date: string;
|
end_date: string;
|
||||||
attendees: Record<string, string>;
|
attendees: Record<string, string>;
|
||||||
available_tags: string[];
|
available_tags: string[];
|
||||||
|
overlap_tags: string[];
|
||||||
created_at: string;
|
created_at: string;
|
||||||
events?: TripEvent[];
|
events?: TripEvent[];
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user