+
{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 (
+
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 (
-
+ {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
+
{/* Week Header */}
+ })
+
+ 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
@@ -158,7 +162,7 @@ export function WeekView({ weekStart, events, showRoomColors, onEventClick }: We
{/* Time grid */}
-
+
)
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
{/* Time labels */}
{/* Day columns */}
- {days.map((day, dayIndex) => (
-
{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)}
))}
- {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 (
-
+ )
+ })}
+
+ )
+ })}