diff --git a/README.md b/README.md index 126d6eb..9c83fbf 100644 --- a/README.md +++ b/README.md @@ -93,7 +93,7 @@ Moving an existing shadcn (Radix) app over: see [MIGRATING.md](MIGRATING.md) for | `focus` | `registry:block` | The one loud thing on a page, under its header: a label, a title with a highlight and a row of facts | | `avatar-stack` | `registry:block` | Several people in the space of one; the avatars spread apart when pointed at | | `countdown` | `registry:block` | Time left until a moment, to the minute, with rolling digits | -| `week-calendar` | `registry:block` | One week in 7 columns like a month view of Calendar: all-day events as pills, timed ones with a bar and the start time, today circled; one row per day below `md` | +| `week-calendar` | `registry:block` | One week as a time grid like the week view of Calendar: hours down the side (08:00 to 20:00, stretching to fit), timed events as blocks from start to end side by side where they overlap, all-day events as pills above, today circled; one row per day below `md` | | `claude-code` | `registry:block` | Claude Code's screen in a `terminal` for demos and install guides: welcome, prompts, MCP calls, permission questions, the `/plugins` menus (marketplaces, the plugin list with its search box, plugin details) and `/mcp`; content through props; colors are `claude-*` tokens from Claude Code's dark theme | | `typing` | `registry:lib` | Timed keystrokes for typing animations: a steady pace (100ms a key, about 120 WPM) with jitter; Chinese typed as zhuyin that turns into the character on its tone key; `InputBox` shows the zhuyin with `composing` | | `expand-row` | `registry:block` | A list row that opens in place to show its details | diff --git a/app/_docs/demos/week-calendar.tsx b/app/_docs/demos/week-calendar.tsx index 37f8514..f863093 100644 --- a/app/_docs/demos/week-calendar.tsx +++ b/app/_docs/demos/week-calendar.tsx @@ -4,6 +4,7 @@ const at = (day: string, time: string) => `2026-11-${day}T${time}:00+08:00` const events = [ { title: "Lab Meeting", start: at("02", "15:30"), end: at("02", "17:30") }, + { title: "實驗討論", start: at("02", "16:00"), end: at("02", "17:00") }, { title: "專題討論", start: at("02", "19:30"), end: at("02", "20:30") }, { title: "6G 雙週會議", start: at("03", "10:00"), end: at("03", "11:00") }, { diff --git a/app/_docs/docs.ts b/app/_docs/docs.ts index 7eaf237..f201fa5 100644 --- a/app/_docs/docs.ts +++ b/app/_docs/docs.ts @@ -79,7 +79,7 @@ export const docs: Doc[] = [ group: "區塊", slug: "week-calendar", title: "單週行事曆", - description: "一週七天並排,每天列出行程:整天的在上,有時間的在下。", + description: "一週七天並排,時間往下排,行程從開始畫到結束。", item: "week-calendar", layout: "wide", }, diff --git a/registry.json b/registry.json index 64b3fdb..77da67f 100644 --- a/registry.json +++ b/registry.json @@ -1297,7 +1297,7 @@ "name": "week-calendar", "type": "registry:block", "title": "Week Calendar", - "description": "One week of days side by side, each listing its events: all-day ones on top, timed ones under them.", + "description": "One week as a time grid: days in columns, hours down the side, timed events as blocks from start to end, all-day events above.", "dependencies": [ "cn" ], diff --git a/registry/winlab/blocks/week-calendar/week-calendar.tsx b/registry/winlab/blocks/week-calendar/week-calendar.tsx index 222dc8a..aed3751 100644 --- a/registry/winlab/blocks/week-calendar/week-calendar.tsx +++ b/registry/winlab/blocks/week-calendar/week-calendar.tsx @@ -1,3 +1,5 @@ +import * as React from "react" + import { cn } from "@/registry/winlab/lib/utils" export type WeekEvent = { @@ -13,17 +15,71 @@ export type WeekEvent = { const WEEKDAYS = "日一二三四五六" const DAY = 24 * 60 * 60 * 1000 +const FULL = 24 * 60 // Calendar days are YYYY-MM-DD strings, so they compare as text. const addDays = (day: string, n: number) => new Date(Date.parse(`${day}T00:00:00Z`) + n * DAY).toISOString().slice(0, 10) const weekday = (day: string) => new Date(`${day}T00:00:00Z`).getUTCDay() +const pad = (n: number) => String(n).padStart(2, "0") + +type Placed = WeekEvent & { + key: string + first: string + last: string + startMinute: number + endMinute: number + span: string +} -// One week of days in columns, each listing its events the way a month view -// of Calendar does: all-day events as pills on top, timed ones under them -// with a bar, the title and the start time (the whole span in the tip). -// Today's date sits in a primary circle. -// Below md the columns fold into one row per day. +type Block = { + event: Placed + from: number + to: number + lane: number + lanes: number +} + +// The timed events of one day, cut to the day, side by side where they +// overlap: each cluster of overlapping events shares the column in lanes. +function blocks(day: string, events: Placed[]): Block[] { + const items = events + .filter((e) => !e.allDay && e.first <= day && e.last >= day) + .map((event) => ({ + event, + from: event.first === day ? event.startMinute : 0, + to: event.last === day ? event.endMinute : FULL, + lane: 0, + lanes: 1, + })) + .sort((a, b) => a.from - b.from || b.to - a.to) + let cluster: typeof items = [] + let clusterEnd = -1 + const close = () => { + const lanes = Math.max(...cluster.map((item) => item.lane)) + 1 + for (const item of cluster) item.lanes = lanes + } + for (const item of items) { + if (item.from >= clusterEnd && cluster.length) { + close() + cluster = [] + } + const taken = new Set( + cluster.filter((other) => other.to > item.from).map((o) => o.lane) + ) + while (taken.has(item.lane)) item.lane++ + cluster.push(item) + clusterEnd = Math.max(clusterEnd, item.to) + } + if (cluster.length) close() + return items +} + +// One week the way the week view of Calendar draws it: days in columns, +// hours down the side, each timed event a block from its start to its end, +// all-day events as pills above the hours. The hours run 08:00 to 20:00 and +// stretch to fit any event outside them. Today's date sits in a primary +// circle. Below md the columns fold into one row per day, listing events. function WeekCalendar({ from, events, @@ -41,18 +97,32 @@ function WeekCalendar({ timeZone?: string className?: string }) { + const scope = React.useId().replace(/:/g, "") const dayOf = new Intl.DateTimeFormat("en-CA", { timeZone }) - const clock = new Intl.DateTimeFormat("en-GB", { + const clockParts = new Intl.DateTimeFormat("en-GB", { timeZone, hour: "2-digit", minute: "2-digit", + hourCycle: "h23", }) + const minuteOf = (date: Date) => { + const parts = clockParts.formatToParts(date) + const get = (type: string) => + Number(parts.find((part) => part.type === type)?.value ?? 0) + return get("hour") * 60 + get("minute") + } + const clock = (minute: number) => + `${pad(Math.floor(minute / 60))}:${pad(minute % 60)}` + const list = Array.from({ length: days }, (_, i) => addDays(from, i)) - const placed = events.map((event) => { + const placed: Placed[] = events.map((event) => { const start = new Date(event.start) const end = new Date(event.end) // An event ending at midnight belongs to the day before. const last = new Date(Math.max(start.getTime(), end.getTime() - 1)) + const startMinute = minuteOf(start) + const endMinute = + dayOf.format(end) === dayOf.format(last) ? minuteOf(end) : FULL return { ...event, key: @@ -60,90 +130,200 @@ function WeekCalendar({ `${start.toISOString()}-${end.toISOString()}-${event.title}`, first: dayOf.format(start), last: dayOf.format(last), - time: clock.format(start), - span: `${clock.format(start)} 到 ${clock.format(end)}`, - at: start.getTime(), + startMinute, + endMinute, + span: `${clock(startMinute)} 到 ${clock(endMinute % FULL)}`, } }) + const perDay = list.map((day) => ({ + day, + allDay: placed.filter((e) => e.allDay && e.first <= day && e.last >= day), + timed: blocks(day, placed), + })) + + const all = perDay.flatMap((d) => d.timed) + const startHour = Math.min(8, ...all.map((b) => Math.floor(b.from / 60))) + const endHour = Math.max(20, ...all.map((b) => Math.ceil(b.to / 60))) + const hours = Array.from( + { length: endHour - startHour }, + (_, i) => startHour + i + ) + const total = (endHour - startHour) * 60 + const percent = (n: number) => `${((n / total) * 100).toFixed(3)}%` + const rules = perDay + .flatMap(({ timed }, d) => + timed.map( + (b, i) => + `[data-week-scope=${scope}] [data-block="${d}-${i}"] { --top: ${percent(b.from - startHour * 60)}; --height: ${percent(b.to - b.from)}; --left: ${((b.lane / b.lanes) * 100).toFixed(3)}%; --width: ${(100 / b.lanes).toFixed(3)}%; }` + ) + ) + .join("\n") + const hasAllDay = perDay.some((d) => d.allDay.length) + + const dateLabel = (day: string, index: number) => { + const date = Number(day.slice(8)) + return index === 0 || date === 1 + ? `${Number(day.slice(5, 7))}/${date}` + : date + } + const dateCircle = (day: string, index: number) => ( + + {dateLabel(day, index)} + + ) + const pill = (event: Placed) => ( +
  • + {event.title} +
  • + ) return (
    - {list.map((day, index) => { - const on = placed - .filter((event) => event.first <= day && event.last >= day) - .sort( - (a, b) => Number(!!b.allDay) - Number(!!a.allDay) || a.at - b.at - ) - const date = Number(day.slice(8)) - const label = - index === 0 || date === 1 - ? `${Number(day.slice(5, 7))}/${date}` - : date - return ( + + + {/* md and up: the time grid. */} +
    +
    +
    +
    + {list.map((day, index) => ( +
    + + {WEEKDAYS[weekday(day)]} + + {dateCircle(day, index)} +
    + ))} +
    +
    + {hasAllDay && ( +
    +
    整天
    +
    + {perDay.map(({ day, allDay }) => ( +
      + {allDay.map(pill)} +
    + ))} +
    +
    + )} +
    +
    + {hours.map((hour) => ( +
    + + {hour === startHour ? "" : `${pad(hour)}:00`} + +
    + ))} +
    +
    +
    + {hours.map((hour) => ( +
    + ))} +
    + {perDay.map(({ day, timed }, d) => ( +
      + {timed.map((b, i) => ( +
    • +
      + + {b.event.title} + + + {clock(b.event.startMinute)} + +
      +
    • + ))} +
    + ))} +
    +
    +
    + + {/* Below md: one row per day. */} +
    + {perDay.map(({ day, allDay, timed }, index) => (
    -
    +
    {WEEKDAYS[weekday(day)]} - - {label} - + {dateCircle(day, index)}
      - {on.map((event) => - event.allDay ? ( -
    • - {event.title} -
    • - ) : ( -
    • ( +
    • + - - {event.title} - - - {event.time} - -
    • - ) - )} + {b.event.title} + + + {b.event.span} + + + ))}
    - ) - })} + ))} +
    ) }