diff --git a/frontend(experimental)/newchat/app/layout.tsx b/frontend(experimental)/newchat/app/layout.tsx index 0778c98..95ec418 100644 --- a/frontend(experimental)/newchat/app/layout.tsx +++ b/frontend(experimental)/newchat/app/layout.tsx @@ -18,8 +18,8 @@ export default function RootLayout({ children: React.ReactNode }>) { return ( - - + + {children} diff --git a/frontend(experimental)/newchat/app/page.tsx b/frontend(experimental)/newchat/app/page.tsx index 3114318..5238efe 100644 --- a/frontend(experimental)/newchat/app/page.tsx +++ b/frontend(experimental)/newchat/app/page.tsx @@ -1,11 +1,10 @@ "use client" import { useMemo, useState } from "react" -import Image from "next/image" import { Menu, Plus, Loader2 } from "lucide-react" import { useAuth } from "@/lib/auth-context" -import { useRooms, useEvents } from "@/lib/hooks" +import { useRooms, useEvents, useMyResponses } from "@/lib/hooks" import { AuthScreen } from "@/components/auth-screen" import { Sidebar } from "@/components/sidebar" import { WeekView } from "@/components/week-view" @@ -13,6 +12,7 @@ import { CreateEventDialog } from "@/components/create-event-dialog" import { EventDetailDialog } from "@/components/event-detail-dialog" import { CreateRoomDialog, JoinRoomDialog } from "@/components/room-dialogs" import { RoomSettingsDialog } from "@/components/room-settings-dialog" +import { RoomBackground } from "@/components/room-background" import { getRoomBackground } from "@/lib/room-theme" import { addDays, startOfWeek, toDateParam, formatMonthYear } from "@/lib/date-utils" import type { CalendarEvent, User } from "@/lib/types" @@ -62,6 +62,8 @@ function CalendarApp({ user, onLogout }: { user: User; onLogout: () => void }) { mutate: mutateEvents, } = useEvents(activeRoomId, from, to) + const { myResponses } = useMyResponses(events, user.id) + const activeRoom = rooms.find((r) => r.id === activeRoomId) const background = getRoomBackground(activeRoomId) @@ -71,17 +73,11 @@ function CalendarApp({ user, onLogout }: { user: User; onLogout: () => void }) { } return ( -
- +
+
-
+
{/* Mobile sidebar backdrop */} {sidebarOpen && (
void }) { />
-
+
-
+
{eventsLoading ? (
@@ -158,7 +154,7 @@ function CalendarApp({ user, onLogout }: { user: User; onLogout: () => void }) { setDialog({ type: "event-detail", event })} /> )} @@ -226,4 +222,4 @@ function CalendarApp({ user, onLogout }: { user: User; onLogout: () => void }) { )}
) -} +} \ No newline at end of file diff --git a/frontend(experimental)/newchat/components/room-background.tsx b/frontend(experimental)/newchat/components/room-background.tsx new file mode 100644 index 0000000..296e070 --- /dev/null +++ b/frontend(experimental)/newchat/components/room-background.tsx @@ -0,0 +1,81 @@ +"use client" + +import { useEffect, useRef, useState } from "react" +import Image from "next/image" + +interface RoomBackgroundProps { + src: string + durationMs?: number +} + +interface Layer { + id: number + src: string +} + +// Stacks background images and crossfades between them whenever `src` +// changes. Each new layer only starts fading in once it has actually +// finished loading, and layers below a fully-faded-in layer are dropped +// on transitionend rather than a guessed timeout — this keeps rapid +// room switches (and slow/cold-cache images) visually consistent. +export function RoomBackground({ src, durationMs = 700 }: RoomBackgroundProps) { + const idRef = useRef(0) + const [layers, setLayers] = useState(() => [{ id: idRef.current, src }]) + const [revealed, setRevealed] = useState>(() => new Set([idRef.current])) + + useEffect(() => { + setLayers((prev) => { + if (prev[prev.length - 1]?.src === src) return prev + idRef.current += 1 + return [...prev, { id: idRef.current, src }] + }) + }, [src]) + + const reveal = (id: number) => { + setRevealed((prev) => { + if (prev.has(id)) return prev + const next = new Set(prev) + next.add(id) + return next + }) + } + + const handleTopLayerTransitionEnd = (id: number) => { + // Once the newest layer has finished fading in, everything below it + // is fully hidden and can be dropped. + setLayers((prev) => { + const idx = prev.findIndex((l) => l.id === id) + if (idx <= 0) return prev + return prev.slice(idx) + }) + } + + const topId = layers[layers.length - 1]?.id + + return ( +
+ {layers.map((layer, i) => { + const isTop = i === layers.length - 1 + const isRevealed = revealed.has(layer.id) + return ( + reveal(layer.id)} + onTransitionEnd={() => { + if (isTop && layer.id === topId) handleTopLayerTransitionEnd(layer.id) + }} + /> + ) + })} +
+ ) +} \ No newline at end of file diff --git a/frontend(experimental)/newchat/components/week-view.tsx b/frontend(experimental)/newchat/components/week-view.tsx index 1451bf6..560a864 100644 --- a/frontend(experimental)/newchat/components/week-view.tsx +++ b/frontend(experimental)/newchat/components/week-view.tsx @@ -1,21 +1,16 @@ "use client" -import { useMemo } from "react" -import type { CalendarEvent } from "@/lib/types" -import { getRoomColor } from "@/lib/room-theme" -import { - addDays, - isSameDay, - parseDateTime, - WEEKDAY_LABELS, -} from "@/lib/date-utils" +import { useEffect, useMemo, useRef } from "react" +import type { CalendarEvent, SignupStatus } from "@/lib/types" +import { STRIPE_STYLE, getEventStyle } from "@/lib/room-theme" +import { addDays, isSameDay, parseDateTime, WEEKDAY_LABELS } from "@/lib/date-utils" const HOUR_HEIGHT = 64 // px per hour interface WeekViewProps { weekStart: Date events: CalendarEvent[] - showRoomColors: boolean + myResponses: Record onEventClick: (event: CalendarEvent) => void } @@ -29,10 +24,20 @@ interface PositionedEvent { lanes: number } +// Military (24h) time, e.g. "09:00", "14:30". +function formatMilitaryTime(date: Date): string { + const h = String(date.getHours()).padStart(2, "0") + const m = String(date.getMinutes()).padStart(2, "0") + return `${h}:${m}` +} + +function formatHourLabel(h: number): string { + return `${String(h % 24).padStart(2, "0")}:00` +} + // Assign overlapping events to side-by-side lanes within a single day. function layoutDayEvents( events: { event: CalendarEvent; start: Date; end: Date }[], - hoursStart: number, ): PositionedEvent[] { const sorted = [...events].sort((a, b) => a.start.getTime() - b.start.getTime()) const result: PositionedEvent[] = [] @@ -62,7 +67,7 @@ function layoutDayEvents( event: item.event, start: item.start, end: item.end, - top: (startHrs - hoursStart) * HOUR_HEIGHT, + top: startHrs * HOUR_HEIGHT, height: (safeEnd - startHrs) * HOUR_HEIGHT, lane: item.lane, lanes, @@ -86,32 +91,18 @@ function layoutDayEvents( return result } -export function WeekView({ weekStart, events, showRoomColors, onEventClick }: WeekViewProps) { +export function WeekView({ weekStart, events, myResponses, onEventClick }: WeekViewProps) { + const scrollRef = useRef(null) + const hasAutoScrolled = useRef(false) + const days = useMemo( () => Array.from({ length: 7 }, (_, i) => addDays(weekStart, i)), [weekStart], ) - // Determine visible hour range from events (clamped to a sensible default). - const { hoursStart, hoursEnd } = useMemo(() => { - let min = 8 - let max = 18 - for (const e of events) { - const s = parseDateTime(e.start_time) - const en = parseDateTime(e.end_time) - if (!isNaN(s.getTime())) min = Math.min(min, s.getHours()) - if (!isNaN(en.getTime())) { - const endHr = en.getMinutes() > 0 ? en.getHours() + 1 : en.getHours() - max = Math.max(max, endHr) - } - } - return { hoursStart: Math.max(0, min), hoursEnd: Math.min(24, Math.max(max, min + 6)) } - }, [events]) - - const timeSlots = useMemo( - () => Array.from({ length: hoursEnd - hoursStart }, (_, i) => hoursStart + i), - [hoursStart, hoursEnd], - ) + // Full 24-hour day, always. + const timeSlots = useMemo(() => Array.from({ length: 24 }, (_, i) => i), []) + const gridHeight = 24 * HOUR_HEIGHT const positionedByDay = useMemo(() => { return days.map((day) => { @@ -122,21 +113,34 @@ export function WeekView({ weekStart, events, showRoomColors, onEventClick }: We end: parseDateTime(event.end_time), })) .filter((x) => !isNaN(x.start.getTime()) && isSameDay(x.start, day)) - return layoutDayEvents(dayEvents, hoursStart) + return layoutDayEvents(dayEvents) }) - }, [days, events, hoursStart]) + }, [days, events]) const today = new Date() - const gridHeight = (hoursEnd - hoursStart) * HOUR_HEIGHT + const nowMinutes = today.getHours() * 60 + today.getMinutes() + const nowTop = (nowMinutes / 60) * HOUR_HEIGHT + const todayColumnIndex = days.findIndex((d) => isSameDay(d, today)) - const formatHour = (h: number) => { - if (h === 0) return "12 AM" - if (h === 12) return "12 PM" - return h > 12 ? `${h - 12} PM` : `${h} AM` - } + // Auto-scroll so the current time sits vertically centered in the + // scrollable area, once per mount (and whenever the visible week + // changes to include today). + useEffect(() => { + if (!scrollRef.current) return + if (todayColumnIndex === -1) return + if (hasAutoScrolled.current) return + + const container = scrollRef.current + const target = nowTop - container.clientHeight / 2 + container.scrollTop = Math.max(0, target) + hasAutoScrolled.current = true + }, [todayColumnIndex, nowTop]) return ( -
+
{/* Week Header */}
@@ -158,7 +162,7 @@ export function WeekView({ weekStart, events, showRoomColors, onEventClick }: We
{/* Time grid */} -
+
{/* Time labels */}
{timeSlots.map((hour) => ( @@ -167,58 +171,78 @@ export function WeekView({ weekStart, events, showRoomColors, onEventClick }: We className="border-b border-white/10 pr-2 text-right text-xs text-white/70" style={{ height: HOUR_HEIGHT }} > - {formatHour(hour)} + {formatHourLabel(hour)}
))}
{/* Day columns */} - {days.map((day, dayIndex) => ( -
- {timeSlots.map((hour) => ( -
- ))} + {days.map((day, dayIndex) => { + const isToday = isSameDay(day, today) + return ( +
+ {timeSlots.map((hour) => ( +
+ ))} - {positionedByDay[dayIndex].map((pe, i) => { - const color = showRoomColors ? getRoomColor(pe.event.room_id) : { bg: "bg-blue-500" } - const cancelled = pe.event.status === "cancelled" - const tentative = pe.event.status === "draft" || pe.event.status === "proposed" - const widthPct = 100 / pe.lanes - return ( - - ) - })} -
- ))} + + ) + })} +
+ ) + })}
) diff --git a/frontend(experimental)/newchat/lib/date-utils.ts b/frontend(experimental)/newchat/lib/date-utils.ts index 2909d7f..459b866 100644 --- a/frontend(experimental)/newchat/lib/date-utils.ts +++ b/frontend(experimental)/newchat/lib/date-utils.ts @@ -55,7 +55,7 @@ export function toInputDateTime(date: Date): string { export function formatTime(date: Date): string { if (isNaN(date.getTime())) return "--:--" - return date.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" }) + return date.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit", hour12: false }) } export function formatDayLabel(date: Date): string { diff --git a/frontend(experimental)/newchat/lib/hooks.ts b/frontend(experimental)/newchat/lib/hooks.ts index b5d2867..6e39895 100644 --- a/frontend(experimental)/newchat/lib/hooks.ts +++ b/frontend(experimental)/newchat/lib/hooks.ts @@ -2,6 +2,7 @@ import useSWR from "swr" import * as api from "./api" +import type { CalendarEvent, SignupStatus } from "./types" export function useRooms() { const { data, error, isLoading, mutate } = useSWR("rooms", () => api.getRooms()) @@ -34,3 +35,31 @@ export function useRoomPermissions(roomId: number | null) { ) return { permissions: data, error, isLoading, mutate } } + +// Bulk-fetch the current user's own RSVP status for every visible proposal +// event, so the week view can color-code events the user has declined. +// Only proposal-type events carry RSVPs, so we skip busy/private blocks. +export function useMyResponses(events: CalendarEvent[], currentUserId: number | null) { + const proposalIds = events + .filter((e) => e.type === "proposal") + .map((e) => e.id) + .sort((a, b) => a - b) + + const key = + currentUserId !== null && proposalIds.length > 0 + ? ["my-responses", currentUserId, proposalIds.join(",")] + : null + + const { data, isLoading } = useSWR(key, async () => { + const entries = await Promise.all( + proposalIds.map(async (id) => { + const responses = await api.getEventResponses(id) + const mine = responses.find((r) => r.user_id === currentUserId)?.status + return [id, mine] as [number, SignupStatus | undefined] + }), + ) + return Object.fromEntries(entries) as Record + }) + + return { myResponses: data ?? {}, isLoading } +} diff --git a/frontend(experimental)/newchat/lib/room-theme.ts b/frontend(experimental)/newchat/lib/room-theme.ts index 2fea41e..b742114 100644 --- a/frontend(experimental)/newchat/lib/room-theme.ts +++ b/frontend(experimental)/newchat/lib/room-theme.ts @@ -1,5 +1,8 @@ // Deterministic per-room theming: background image + accent color. +import type { CSSProperties } from "react" +import type { CalendarEvent, SignupStatus } from "./types" + const ROOM_BACKGROUNDS = [ "/backgrounds/room-1.png", "/backgrounds/room-2.png", @@ -27,6 +30,85 @@ export function getRoomBackground(roomId: number | null): string { return ROOM_BACKGROUNDS[roomId % ROOM_BACKGROUNDS.length] } -export function getRoomColor(roomId: number) { - return ROOM_COLORS[roomId % ROOM_COLORS.length] +// Guarded against null/undefined/NaN room ids (e.g. personal blocks that +// arrive without a valid room_id) so we never hand back an undefined color, +// which previously rendered as no background color at all. +export function getRoomColor(roomId: number | null | undefined) { + const id = typeof roomId === "number" && Number.isFinite(roomId) ? roomId : 0 + const idx = ((id % ROOM_COLORS.length) + ROOM_COLORS.length) % ROOM_COLORS.length + return ROOM_COLORS[idx] +} + +// ---------- Event coloring by type / status / RSVP ---------- +// +// Confirmed and busy events each pick a color from a small themed pool, +// deterministically by room so the same room always looks the same. +// Private, proposed, declined and cancelled get one fixed look each so +// they're recognizable at a glance regardless of room. + +export interface EventStyle { + bg: string + ring: string + /** Dashed border — signals "not yet settled" (pending proposal). */ + dashed?: boolean + /** Diagonal hatch pattern — signals "you've declined this". */ + striped?: boolean + /** Reduced opacity — signals "you're not going". */ + muted?: boolean + /** Outline only, transparent fill — signals "private, details hidden". */ + outline?: boolean +} + +const CONFIRMED_PALETTE = [ + { bg: "bg-emerald-500", ring: "ring-emerald-400" }, + { bg: "bg-blue-500", ring: "ring-blue-400" }, + { bg: "bg-purple-500", ring: "ring-purple-400" }, +] + +const BUSY_PALETTE = [ + { bg: "bg-orange-500", ring: "ring-orange-400" }, + { bg: "bg-yellow-400", ring: "ring-yellow-300" }, + { bg: "bg-red-500", ring: "ring-red-400" }, +] + +function pickFromPalette( + palette: { bg: string; ring: string }[], + roomId: number | null | undefined, +) { + const id = typeof roomId === "number" && Number.isFinite(roomId) ? roomId : 0 + const idx = ((id % palette.length) + palette.length) % palette.length + return palette[idx] +} + +const PRIVATE_STYLE: EventStyle = { bg: "bg-white/10", ring: "ring-white/60", outline: true } +const PROPOSED_STYLE: EventStyle = { bg: "bg-white/25", ring: "ring-white/50", dashed: true } +const CANCELLED_STYLE: EventStyle = { bg: "bg-red-500", ring: "ring-red-400" } +const DECLINED_STYLE: EventStyle = { + bg: "bg-neutral-500", + ring: "ring-neutral-400", + striped: true, + muted: true, +} + +// Inline CSS for diagonal hatch patterns — Tailwind has no built-in +// striped-background utility, so this is applied via style prop. +export const STRIPE_STYLE: CSSProperties = { + backgroundImage: + "repeating-linear-gradient(135deg, rgba(255,255,255,0.18) 0px, rgba(255,255,255,0.18) 6px, transparent 6px, transparent 12px)", +} + +export function getEventStyle( + event: Pick, + myResponse: SignupStatus | undefined, +): EventStyle { + if (event.type === "private") return PRIVATE_STYLE + if (event.type === "busy") return pickFromPalette(BUSY_PALETTE, event.room_id) + + // Proposal-type events: colored by where they stand. + if (event.status === "cancelled") return CANCELLED_STYLE + if (myResponse === "declined") return DECLINED_STYLE + if (event.status === "proposed" || event.status === "draft") return PROPOSED_STYLE + + // Confirmed proposal: pick from the confirmed palette, by room. + return pickFromPalette(CONFIRMED_PALETTE, event.room_id) } diff --git a/frontend(experimental)/newchat/tailwind.config.js b/frontend(experimental)/newchat/tailwind.config.js index 3e8edb5..c25c107 100644 --- a/frontend(experimental)/newchat/tailwind.config.js +++ b/frontend(experimental)/newchat/tailwind.config.js @@ -5,6 +5,7 @@ module.exports = { "./pages/**/*.{ts,tsx}", "./components/**/*.{ts,tsx}", "./app/**/*.{ts,tsx}", + "./lib/**/*.{ts,tsx}", "./src/**/*.{ts,tsx}", "*.{js,ts,jsx,tsx,mdx}", ], diff --git a/frontend(experimental)/newchat_updated.tar.gz b/frontend(experimental)/newchat_updated.tar.gz new file mode 100644 index 0000000..6154eb1 Binary files /dev/null and b/frontend(experimental)/newchat_updated.tar.gz differ