Compare commits
2 Commits
4dd3343026
...
981f03ac06
| Author | SHA1 | Date | |
|---|---|---|---|
| 981f03ac06 | |||
| 47430827d4 |
@@ -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):
|
||||
|
||||
@@ -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}")
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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) =>
|
||||
|
||||
@@ -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[];
|
||||
}
|
||||
|
||||
@@ -181,8 +181,9 @@ class TripCommands(commands.Cog):
|
||||
f"{_fmt_date(t.get('end_date', ''))}"
|
||||
)
|
||||
attendee_count = len(t.get("attendees", {}))
|
||||
field_name = f"{t['name']} [{status}]"[:256]
|
||||
embed.add_field(
|
||||
name=f"{t['name']} [{status}]",
|
||||
name=field_name,
|
||||
value=f"{t.get('location', '?')}\n{dates}\n{attendee_count} going",
|
||||
inline=False,
|
||||
)
|
||||
@@ -215,8 +216,8 @@ class TripCommands(commands.Cog):
|
||||
)
|
||||
|
||||
embed = discord.Embed(
|
||||
title=data["name"],
|
||||
description="\n".join(desc_lines),
|
||||
title=data["name"][:256],
|
||||
description="\n".join(desc_lines)[:4096],
|
||||
color=0x5865f2,
|
||||
)
|
||||
|
||||
@@ -225,19 +226,21 @@ class TripCommands(commands.Cog):
|
||||
for e in data.get("events", []):
|
||||
events_by_date.setdefault(e["date"], []).append(e)
|
||||
|
||||
# Track total embed chars (Discord limit: 6000)
|
||||
embed_chars = len(embed.title or "") + len(embed.description or "")
|
||||
field_count = 0
|
||||
for day_iso in _date_range(data["start_date"], data["end_date"]):
|
||||
day_events = events_by_date.get(day_iso)
|
||||
if not day_events:
|
||||
continue
|
||||
if field_count >= 24:
|
||||
if field_count >= 24 or embed_chars >= 5800:
|
||||
embed.add_field(name="...", value="More events not shown.", inline=False)
|
||||
break
|
||||
|
||||
day_label = datetime.strptime(day_iso, "%Y-%m-%d").strftime("%A, %b %-d")
|
||||
lines = []
|
||||
for e in sorted(day_events, key=lambda x: x.get("time") or ""):
|
||||
time_str = _fmt_time(e.get("time"))
|
||||
for e in sorted(day_events, key=lambda x: x.get("start_time") or ""):
|
||||
time_str = _fmt_time(e.get("start_time"))
|
||||
line = f"**{time_str}** {e['title']}" if time_str else f"- {e['title']}"
|
||||
|
||||
loc = e.get("location")
|
||||
@@ -258,7 +261,12 @@ class TripCommands(commands.Cog):
|
||||
|
||||
lines.append(line)
|
||||
|
||||
embed.add_field(name=f"— {day_label} —", value="\n".join(lines), inline=False)
|
||||
field_name = f"— {day_label} —"
|
||||
field_value = "\n".join(lines)
|
||||
if len(field_value) > 1024:
|
||||
field_value = field_value[:1021] + "…"
|
||||
embed.add_field(name=field_name, value=field_value, inline=False)
|
||||
embed_chars += len(field_name) + len(field_value)
|
||||
field_count += 1
|
||||
|
||||
if not events_by_date:
|
||||
|
||||
Reference in New Issue
Block a user