allow overlap (note) tags

This commit is contained in:
Logan
2026-06-21 15:52:15 -04:00
parent 47430827d4
commit 981f03ac06
5 changed files with 98 additions and 27 deletions
+1
View File
@@ -147,6 +147,7 @@ class TripCreate(BaseModel):
start_date: str # YYYY-MM-DD
end_date: str # YYYY-MM-DD
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):
+5 -3
View File
@@ -208,6 +208,7 @@ async def create_trip(body: TripCreate):
"end_date": body.end_date,
"attendees": {}, # {discord_user_id: discord_username}
"available_tags": body.available_tags,
"overlap_tags": body.overlap_tags,
"created_at": now,
}
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")
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)
if not trip:
raise HTTPException(404, f"Trip '{trip_id}' not found.")
tags = [str(t) for t in body.get("available_tags", []) if t]
await fstore.doc_update("trips", trip_id, {"available_tags": tags})
return {"available_tags": tags}
overlap = [str(t) for t in body.get("overlap_tags", []) if t and t in 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}")
+89 -22
View File
@@ -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>
+2 -2
View File
@@ -142,8 +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 }) }),
updateTripTags: (id: string, available_tags: string[], overlap_tags: string[]) =>
request<{ available_tags: string[]; overlap_tags: string[] }>(`/trips/${id}/tags`, { method: "PUT", body: JSON.stringify({ available_tags, overlap_tags }) }),
createTripEvent: (tripId: string, body: object) =>
request<import("@/lib/types").TripEvent>(`/trips/${tripId}/events`, { method: "POST", body: JSON.stringify(body) }),
updateTripEvent: (tripId: string, eventId: string, body: object) =>
+1
View File
@@ -134,6 +134,7 @@ export interface TripRecord {
end_date: string;
attendees: Record<string, string>;
available_tags: string[];
overlap_tags: string[];
created_at: string;
events?: TripEvent[];
}