ik hou van kaas

This commit is contained in:
ben
2026-08-19 11:56:44 +02:00
parent 8fe812e8e6
commit 468c34ff92
9 changed files with 320 additions and 107 deletions
@@ -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 {
@@ -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<number, SignupStatus | undefined>
})
return { myResponses: data ?? {}, isLoading }
}
@@ -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<CalendarEvent, "type" | "status" | "room_id">,
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)
}