Welcome cards
The greeting at the top of a dashboard, with the one thing to do next.
Greeting with the week in figures
welcome-cards-01Good afternoon and the day, the store's week so far in a sentence and three figures against last week by this time, the month against its target with the pace in words, and one button: ship what is paid for.
import * as React from "react"
import Link from "next/link"
import { ArrowRightIcon } from "lucide-react"
import { cn } from "@/lib/utils"
import { formatCurrency, formatNumber, formatPercent } from "@/lib/format"
import { buttonVariants } from "@/components/ui/button"
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
import { EmptyState } from "@/components/ui/empty-state"
import { Progress } from "@/components/ui/progress"
import { StatCard } from "@/components/ui/stat-card"
import { StatCardGroup } from "@/components/ui/stat-card-group"
import { StatusBadge } from "@/components/ui/status-badge"
import { FULFILLMENT_HREF, TARGET_GROWTH, greeting, monthTarget, toShip, weekSoFar, type WeekFigure } from "./welcome-cards-01.data"
/** Whole dollars on a headline; the cents are noise at this size. */
const money = (cents: number) => formatCurrency(cents / 100, "USD", { maximumFractionDigits: 0 })
const show = (figure: WeekFigure, value: number) => (figure.kind === "money" ? money(value) : formatNumber(value))
const ordersWord = (count: number) => `${formatNumber(count)} ${count === 1 ? "order" : "orders"}`
/** The week in one sentence: what came in, from how many orders, and which way against the week before. */
function lead(figures: WeekFigure[]): string {
const revenue = figures.find((figure) => figure.key === "revenue")!
const orders = figures.find((figure) => figure.key === "orders")!
// Not "$0 from 0 orders": a week without a sale says so, and what last week had by now.
if (orders.value === 0) {
return orders.previous === 0
? "The store has taken no orders this week, and had none by this time last week."
: `The store has taken no orders this week, against ${money(revenue.previous)} from ${ordersWord(orders.previous)} by this time last week.`
}
const change =
revenue.previous === 0 || revenue.delta === 0
? "level with last week by this time"
: `${formatPercent(Math.abs(revenue.delta), { maximumFractionDigits: 0 })} ${revenue.delta > 0 ? "more" : "less"} than by this time last week`
return `The store has taken ${money(revenue.value)} from ${ordersWord(orders.value)} this week, ${change}.`
}
/**
* The greeting at the top of the store's dashboard: good afternoon and the
* day in the strip; on the sheet the week so far in a sentence and three
* figures — revenue, orders and the average order, each against the same
* days of last week — and beside them the month against its target, with
* the calendar marked on the bar and the pace said in words, and the one
* thing to do next: ship what has been paid for.
*
* A server component: it counts through `./welcome-cards-01.data`, the time
* of day and the date written from `REFERENCE_DATE` in UTC.
*/
export function WelcomeCards01() {
const titleId = React.useId()
const targetId = React.useId()
const { hello, today } = greeting()
const figures = weekSoFar()
const target = monthTarget()
const ship = toShip()
const reached = Math.round(target.share * 100)
return (
<section
data-section="welcome-cards-01"
data-slot="section"
aria-labelledby={titleId}
className="w-full bg-background p-4 text-foreground md:p-6"
>
<div className="mx-auto max-w-7xl">
<Card>
<CardHeader>
<CardTitle>
<h2 id={titleId}>{hello}</h2>
</CardTitle>
<CardDescription>{today}</CardDescription>
</CardHeader>
<CardContent className="p-0">
<div className="grid lg:grid-cols-[minmax(0,1fr)_20rem]">
<div className="flex min-w-0 flex-col gap-5 p-5 md:p-6">
<p className="text-md text-pretty tabular-nums">{lead(figures)}</p>
<StatCardGroup columns={3} divided>
{figures.map((figure) => (
<StatCard
key={figure.key}
size="sm"
label={figure.label}
value={show(figure, figure.value)}
delta={figure.previous === 0 ? undefined : figure.delta}
deltaFormat="percent"
description={`vs ${show(figure, figure.previous)} last week`}
/>
))}
</StatCardGroup>
</div>
<div className="flex flex-col gap-5 border-t border-border p-5 md:p-6 lg:border-t-0 lg:border-s">
{target.target > 0 ? (
<div className="flex flex-col gap-2">
<div className="flex items-baseline justify-between gap-3">
<p id={targetId} className="text-sm font-medium">
{target.month} target
</p>
<StatusBadge
status={target.pace}
variant={target.pace === "ahead" ? "success" : "warning"}
label={target.pace === "ahead" ? "On pace" : "Behind pace"}
size="sm"
/>
</div>
<div className="relative">
<Progress value={Math.min(100, reached)} aria-labelledby={targetId} aria-valuetext={`${reached}% of the target`} />
{/* Where the calendar is: the bar has to reach this tick to be on pace. */}
<span
aria-hidden="true"
className="absolute -top-1 h-3 w-0.5 rounded-full bg-foreground"
style={{ left: `${Math.round(target.elapsed * 100)}%` }}
/>
</div>
<p className="text-xs text-muted-foreground tabular-nums">
{money(target.revenue)} of {money(target.target)} · {reached}% with{" "}
{formatPercent(target.elapsed, { maximumFractionDigits: 0 })} of the month gone
</p>
</div>
) : (
// Not "$0 of $0": a month after one with no sales has nothing to measure against.
<EmptyState
size="sm"
title={`No ${target.month} target yet`}
description={`The target is last month's revenue plus ${formatPercent(TARGET_GROWTH, { maximumFractionDigits: 0 })}, and last month had no sales.`}
/>
)}
{ship > 0 ? (
<div className="mt-auto flex flex-col gap-2">
<p className="type-eyebrow">Next</p>
<Link href={FULFILLMENT_HREF} className={cn(buttonVariants(), "w-full justify-between")}>
Ship {formatNumber(ship)} paid {ship === 1 ? "order" : "orders"}
<ArrowRightIcon aria-hidden="true" data-icon="inline-end" />
</Link>
</div>
) : (
// Not "Ship 0 paid orders": with nothing waiting there is no next thing to press.
<EmptyState
size="sm"
className="mt-auto"
title="Nothing to ship"
description="Every paid order has gone out. The next one to pack shows here."
/>
)}
</div>
</div>
</CardContent>
</Card>
</div>
</section>
)
}
export default WelcomeCards01/**
* What `welcome-cards-01` reads: the greeting at the top of the store's
* dashboard — who is reading and what part of the day it is — with how the
* week is going so far against the same days of the week before, how the
* month stands against its target, and the one thing to do next: ship what
* has been paid for. Everything is counted from `db.orders`; the greeting —
* the reader's first name and the part of the day — is `team-projects`'
* own; "now" is `REFERENCE_DATE`, and every day and week begins at midnight
* UTC, where the data lives.
*
* No target is written down: the month's is last month's revenue plus
* `TARGET_GROWTH`, rounded to two significant figures.
*
* Selectors only, reading `db` inside functions and run on the server.
*/
import { greeting } from "@/lib/dashboards/projects/team"
import { REFERENCE_DATE, db, type Order } from "@/lib/sample-data"
export { greeting }
/** Where the paid orders wait to be packed: the E-commerce dashboard's fulfilment page. */
export const FULFILLMENT_HREF = "/ecommerce/fulfillment"
/** The month's target is last month's revenue plus this share. */
export const TARGET_GROWTH = 0.1
const DAY_MS = 86_400_000
const WEEK_MS = 7 * DAY_MS
/** The orders that count as a sale: paid for and not handed back. */
const SOLD: readonly Order["status"][] = ["paid", "fulfilled"]
const MONTH = new Intl.DateTimeFormat("en-US", { month: "long", timeZone: "UTC" })
export type WeekFigure = {
key: "revenue" | "orders" | "average"
label: string
/** "money" is in minor units. */
kind: "money" | "count"
value: number
/** The same measure over the same days and hours of the week before. */
previous: number
/** `value` against `previous` as a ratio; 0 with nothing to grow from. */
delta: number
}
const growth = (now: number, was: number) => (was === 0 ? 0 : (now - was) / was)
/** Midnight UTC on the Monday of the week `at` falls in. */
function mondayOf(at: Date): number {
const day = Date.UTC(at.getUTCFullYear(), at.getUTCMonth(), at.getUTCDate())
return day - ((at.getUTCDay() + 6) % 7) * DAY_MS
}
function salesBetween(from: number, to: number): Order[] {
return db.orders.all().filter((order) => SOLD.includes(order.status) && order.placedAt.getTime() >= from && order.placedAt.getTime() < to)
}
/** The week so far, Monday to now, against the same span of the week before. */
export function weekSoFar(): WeekFigure[] {
const now = REFERENCE_DATE.getTime()
const monday = mondayOf(REFERENCE_DATE)
const current = salesBetween(monday, now)
const before = salesBetween(monday - WEEK_MS, now - WEEK_MS)
const cents = (orders: Order[]) => orders.reduce((total, order) => total + order.totalCents, 0)
const average = (orders: Order[]) => (orders.length === 0 ? 0 : Math.round(cents(orders) / orders.length))
const figure = (key: WeekFigure["key"], label: string, kind: WeekFigure["kind"], value: number, previous: number): WeekFigure => ({
key,
label,
kind,
value,
previous,
delta: growth(value, previous),
})
return [
figure("revenue", "Revenue", "money", cents(current), cents(before)),
figure("orders", "Orders", "count", current.length, before.length),
figure("average", "Average order", "money", average(current), average(before)),
]
}
/** The orders paid for and not yet fulfilled: what is waiting to be shipped. */
export function toShip(): number {
return db.orders.all().filter((order) => order.status === "paid" && order.placedAt.getTime() <= REFERENCE_DATE.getTime()).length
}
/** Rounds to two significant figures: 24,732 becomes 25,000. */
function roundTarget(value: number): number {
if (value <= 0) return 0
const scale = 10 ** (Math.floor(Math.log10(value)) - 1)
return Math.round(value / scale) * scale
}
export type MonthTarget = {
month: string
/** Revenue so far this month, in minor units. */
revenue: number
target: number
/** `revenue` over `target`, 0..1 and past it. */
share: number
/** How much of the month has run, 0..1. */
elapsed: number
/** Whether the month so far keeps up with the calendar. */
pace: "ahead" | "behind"
}
/** The month so far against its target: last month's revenue plus `TARGET_GROWTH`. */
export function monthTarget(): MonthTarget {
const now = REFERENCE_DATE.getTime()
const year = REFERENCE_DATE.getUTCFullYear()
const month = REFERENCE_DATE.getUTCMonth()
const start = Date.UTC(year, month, 1)
const end = Date.UTC(year, month + 1, 1)
const cents = (from: number, to: number) => salesBetween(from, to).reduce((total, order) => total + order.totalCents, 0)
const revenue = cents(start, now)
// Whole dollars, rounded: a target is a round figure, not a sum of orders.
const target = roundTarget((cents(Date.UTC(year, month - 1, 1), start) / 100) * (1 + TARGET_GROWTH)) * 100
const share = target === 0 ? 0 : revenue / target
const elapsed = (now - start) / (end - start)
return { month: MONTH.format(REFERENCE_DATE), revenue, target, share, elapsed, pace: share >= elapsed ? "ahead" : "behind" }
}npx shadcn@latest add @vibra/welcome-cards-01Welcome back, carry on
welcome-cards-02Welcome back and when the reader was last here, the task of theirs they touched last with the step it is on and when it is due, one button to carry on, and the other things they had open.
import * as React from "react"
import Link from "next/link"
import {
ArchiveIcon,
ArrowRightIcon,
FileIcon,
FileTextIcon,
FilmIcon,
ImageIcon,
ListChecksIcon,
MusicIcon,
type LucideIcon,
} from "lucide-react"
import { cn } from "@/lib/utils"
import { buttonVariants } from "@/components/ui/button"
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
import { EmptyState } from "@/components/ui/empty-state"
import { Progress } from "@/components/ui/progress"
import { StatusBadge } from "@/components/ui/status-badge"
import { TASKS_HREF, lastTask, recentThings, welcomeBack, type BoardTask, type RecentThing } from "./welcome-cards-02.data"
/** An icon for each sort of thing, so the list reads at a glance; the words say it too. */
const ICONS: Record<string, LucideIcon> = {
task: ListChecksIcon,
document: FileTextIcon,
image: ImageIcon,
video: FilmIcon,
audio: MusicIcon,
archive: ArchiveIcon,
file: FileIcon,
}
function Recent({ thing }: { thing: RecentThing }) {
const Icon = ICONS[thing.sort] ?? FileIcon
return (
<li>
<Link href={thing.href} className="group/recent -mx-2 flex items-center gap-3 rounded-md px-2 py-2 hover:bg-muted/50 focus-ring">
<span className="flex size-8 shrink-0 items-center justify-center rounded-md bg-secondary text-muted-foreground">
<Icon aria-hidden="true" className="size-4" />
</span>
<span className="flex min-w-0 flex-col">
<span className="truncate text-sm font-medium group-hover/recent:underline">{thing.title}</span>
<span className="text-xs text-muted-foreground">
<span className="capitalize">{thing.sort}</span> · <time dateTime={thing.iso}>{thing.touched}</time>
</span>
</span>
</Link>
</li>
)
}
/** The task they touched last: where it sits, the step it is on, when it is due, and one button to carry on. */
function PickUp({ task, touched }: { task: BoardTask; touched: string }) {
const taskTitleId = React.useId()
const stepsId = React.useId()
const done = task.subtasks.filter((step) => step.done).length
const next = task.subtasks.find((step) => !step.done)
return (
<article aria-labelledby={taskTitleId} className="flex min-w-0 flex-col gap-4 p-5 md:p-6">
<p className="type-eyebrow">Pick up where you left off · touched {touched}</p>
<div className="flex flex-col gap-2">
<p className="flex flex-wrap items-center gap-2 text-xs text-muted-foreground">
<span className="font-mono">{task.project.code}</span>
<span>{task.project.name}</span>
<StatusBadge status={task.status} label={task.statusLabel} size="sm" />
</p>
<h3 id={taskTitleId} className="type-display text-xl text-balance">
{task.title}
</h3>
</div>
<div className="flex max-w-xl flex-col gap-2">
<div className="flex items-baseline justify-between gap-3 text-sm">
<span id={stepsId}>{next ? <>Next step: <span className="font-medium">{next.title}</span></> : "Every step is done"}</span>
<span className="shrink-0 text-muted-foreground tabular-nums">
{done} of {task.subtasks.length} steps
</span>
</div>
<Progress value={task.progress} aria-labelledby={stepsId} aria-valuetext={`${done} of ${task.subtasks.length} steps done`} />
<p className="text-xs tabular-nums">
<time
dateTime={task.due.iso}
className={cn(task.due.overdue ? "font-medium text-danger" : "text-muted-foreground")}
>
{task.due.words}
</time>
<span className="text-muted-foreground"> · due {task.due.label}</span>
</p>
</div>
<Link href={task.href} className={cn(buttonVariants(), "mt-auto self-start")}>
Carry on with this task
<ArrowRightIcon aria-hidden="true" data-icon="inline-end" />
</Link>
</article>
)
}
/**
* A welcome back that carries on: when the reader was last here, the task of
* theirs they touched last — its project, its column, how many of its steps
* are done, the step it is on and when it is due, late said in words — with
* one button to carry on with it, and beside it the other things they had
* open most recently.
*
* A server component: it reads through `./welcome-cards-02.data`, every time
* written from `REFERENCE_DATE` in UTC.
*/
export function WelcomeCards02() {
const titleId = React.useId()
const recentId = React.useId()
const { hello, lastHere } = welcomeBack()
const last = lastTask()
const things = recentThings()
return (
<section
data-section="welcome-cards-02"
data-slot="section"
aria-labelledby={titleId}
className="w-full bg-background p-4 text-foreground md:p-6"
>
<div className="mx-auto max-w-7xl">
<Card>
<CardHeader>
<CardTitle>
<h2 id={titleId}>{hello}</h2>
</CardTitle>
{lastHere ? <CardDescription className="tabular-nums">{lastHere}</CardDescription> : null}
</CardHeader>
<CardContent className="p-0">
<div className="grid lg:grid-cols-[minmax(0,1fr)_22rem]">
{last ? (
<PickUp task={last.task} touched={last.touched} />
) : (
<div className="p-5 md:p-6">
<EmptyState
size="sm"
title="Nothing of yours is open"
description="Every task assigned to you is done. The next one you pick up waits here."
action={
<Link href={TASKS_HREF} className={buttonVariants({ variant: "outline", size: "sm" })}>
Open the tasks
</Link>
}
/>
</div>
)}
<div className="flex flex-col gap-2 border-t border-border p-5 md:p-6 lg:border-t-0 lg:border-s">
<p id={recentId} className="type-eyebrow">
Also open recently
</p>
{things.length > 0 ? (
<ul aria-labelledby={recentId} className="flex flex-col">
{things.map((thing) => (
<Recent key={thing.id} thing={thing} />
))}
</ul>
) : (
<p className="text-sm text-muted-foreground">Nothing else of yours changed lately.</p>
)}
</div>
</div>
</CardContent>
</Card>
</div>
</section>
)
}
export default WelcomeCards02/**
* What `welcome-cards-02` reads: a welcome back — when the reader was last
* here, the unfinished task of theirs they touched last, with the step it
* is on, and the other things they had open most recently. The reader is
* `team-projects`' own, the workspace's owner; the task is a `db.tasks` row read
* through `board-projects`, which names its steps and says when it is due the
* way every task band does; the other things are their next most recently
* touched tasks and the newest file of theirs in `db.files`. Every time is
* written here against `REFERENCE_DATE`, in UTC.
*
* Selectors only, reading `db` inside functions and run on the server.
*/
import { TASKS_HREF, boardTask, taskHref, type BoardTask } from "@/lib/dashboards/projects/board"
import { reader } from "@/lib/dashboards/projects/team"
import { REFERENCE_DATE, db, type File } from "@/lib/sample-data"
export { TASKS_HREF }
export type { BoardTask }
/** The drive: the Projects dashboard's files page. */
export const FILES_HREF = "/projects/files"
/** How many other recent things the band lists. */
export const RECENT_COUNT = 3
const DAY_MS = 86_400_000
const TIME = new Intl.DateTimeFormat("en-US", { hour: "2-digit", minute: "2-digit", hourCycle: "h23", timeZone: "UTC" })
const DAY = new Intl.DateTimeFormat("en-US", { month: "short", day: "numeric", timeZone: "UTC" })
const utcDay = (at: Date) => Date.UTC(at.getUTCFullYear(), at.getUTCMonth(), at.getUTCDate())
/** When something happened, as a reader says it from `REFERENCE_DATE`: "today at 11:27", "yesterday at 20:06", "on Aug 13". */
export function when(at: Date): string {
const days = Math.round((utcDay(REFERENCE_DATE) - utcDay(at)) / DAY_MS)
if (days === 0) return `today at ${TIME.format(at)}`
if (days === 1) return `yesterday at ${TIME.format(at)}`
return `on ${DAY.format(at)}`
}
export type RecentThing = {
id: string
kind: "task" | "file"
/** For a file, what sort: a document, an image, an archive… */
sort: string
title: string
href: string
/** When the reader last touched it, in words. */
touched: string
iso: string
}
/** "Welcome back, Sonia", and when they were last here, in UTC; a plain welcome in a store with no members. */
export function welcomeBack(): { hello: string; lastHere: string | null } {
const who = reader()
if (!who) return { hello: "Welcome back", lastHere: null }
return { hello: `Welcome back, ${who.first}`, lastHere: `You were last here ${when(who.lastActiveAt)} UTC` }
}
function openTasks() {
const who = reader()
if (!who) return []
return db.tasks
.all()
.filter((task) => task.assignee === who.id && task.status !== "done")
.sort((a, b) => b.updatedAt.getTime() - a.updatedAt.getTime() || a.id.localeCompare(b.id))
}
/** The reader's unfinished task they touched last, and when; null when nothing of theirs is open. */
export function lastTask(): { task: BoardTask; touched: string } | null {
const [task] = openTasks()
if (!task) return null
return { task: boardTask(task), touched: when(task.updatedAt) }
}
const fileSort = (file: File) => (file.kind === "other" ? "file" : file.kind)
/** What else they had open: their next most recently touched tasks, and their newest file. */
export function recentThings(count = RECENT_COUNT): RecentThing[] {
const who = reader()
const tasks: RecentThing[] = openTasks()
.slice(1, count)
.map((task) => ({ id: task.id, kind: "task", sort: "task", title: task.title, href: taskHref(task.id), touched: when(task.updatedAt), iso: task.updatedAt.toISOString() }))
const file = who
? db.files
.all()
.filter((row) => row.ownerId === who.id && row.kind !== "folder")
.sort((a, b) => b.modifiedAt.getTime() - a.modifiedAt.getTime())[0]
: undefined
const files: RecentThing[] = file
? [{ id: file.id, kind: "file", sort: fileSort(file), title: file.name, href: `${FILES_HREF}?file=${file.id}`, touched: when(file.modifiedAt), iso: file.modifiedAt.toISOString() }]
: []
return [...tasks, ...files].sort((a, b) => b.iso.localeCompare(a.iso)).slice(0, count)
}npx shadcn@latest add @vibra/welcome-cards-02Onboarding progress for a newcomer
welcome-cards-03Welcome and how long ago the newcomer joined, how many first steps are done as a ring, the next step with a button to where it is done, and every step as a checklist of links.
import * as React from "react"
import Link from "next/link"
import { ArrowRightIcon } from "lucide-react"
import { cn } from "@/lib/utils"
import { buttonVariants } from "@/components/ui/button"
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
import { EmptyState } from "@/components/ui/empty-state"
import { OnboardingChecklist } from "@/components/ui/onboarding-checklist"
import { ScoreRing } from "@/components/ui/score-ring"
import { newcomer, setupSteps } from "./welcome-cards-03.data"
/**
* The welcome a newcomer sees until they have settled in: their name and
* how long ago they joined in the strip; on the sheet how many of the first
* steps are done, as a ring, and the one to take next with a button that
* goes where it is done; beside it every step as the kit's checklist, each
* a link to where it happens. A step is done because the store says the
* thing has happened, never because someone ticked it.
*
* A server component: it reads through `./welcome-cards-03.data`.
*/
export function WelcomeCards03() {
const titleId = React.useId()
const nextId = React.useId()
const person = newcomer()
const steps = setupSteps()
const done = steps.filter((step) => step.done).length
const next = steps.find((step) => !step.done)
return (
<section
data-section="welcome-cards-03"
data-slot="section"
aria-labelledby={titleId}
className="w-full bg-background p-4 text-foreground md:p-6"
>
<div className="mx-auto max-w-7xl">
<Card>
<CardHeader>
<CardTitle>
<h2 id={titleId}>{person ? `Welcome to Northwind, ${person.first}` : "Welcome to Northwind"}</h2>
</CardTitle>
{person ? (
<CardDescription className="tabular-nums">
You joined on {person.joined}, {person.days} {person.days === 1 ? "day" : "days"} ago
</CardDescription>
) : null}
</CardHeader>
{person ? (
<CardContent className="p-0">
<div className="grid lg:grid-cols-2">
<div className="flex min-w-0 flex-col gap-6 p-5 md:p-6">
<div className="flex items-center gap-5">
<ScoreRing
value={done}
max={steps.length}
format={(value) => `${value}/${steps.length}`}
size={88}
tone="default"
aria-label="First steps done"
aria-valuetext={`${done} of ${steps.length}`}
/>
<p className="text-md text-pretty">
{next ? (
<>
<span className="font-semibold tabular-nums">
{done} of {steps.length}
</span>{" "}
first steps are done. {steps.length - done === 1 ? "One is" : `${steps.length - done} are`} left, and
the next takes a minute.
</>
) : (
<>Every first step is done. The workspace is yours.</>
)}
</p>
</div>
{next ? (
<div aria-labelledby={nextId} role="group" className="mt-auto flex flex-col gap-3 border-t border-border pt-5">
<p className="type-eyebrow">Next</p>
<div className="flex flex-col gap-1">
<p id={nextId} className="text-sm font-semibold">
{next.title}
</p>
<p className="text-sm text-muted-foreground">{next.description}</p>
</div>
<Link href={next.href} className={cn(buttonVariants(), "self-start")}>
{next.action}
<ArrowRightIcon aria-hidden="true" data-icon="inline-end" />
</Link>
</div>
) : null}
</div>
<div className="border-t border-border p-5 md:p-6 lg:border-t-0 lg:border-s">
<OnboardingChecklist
title="First steps"
steps={steps.map((step) => ({ id: step.id, title: step.title, description: step.description, done: step.done, href: step.href }))}
className="rounded-none border-0 p-0"
/>
</div>
</div>
</CardContent>
) : (
<CardContent>
<EmptyState
size="sm"
title="Nobody new to welcome"
description="When someone joins the workspace, their first steps show here."
action={
<Link href="/people/invitations" className={buttonVariants({ variant: "outline", size: "sm" })}>
Invite a teammate
</Link>
}
/>
</CardContent>
)}
</Card>
</div>
</section>
)
}
export default WelcomeCards03/**
* What `welcome-cards-03` reads: the welcome a newcomer sees until they have
* settled in — the newest active member of the workspace, how long ago they
* joined, and the first steps every member takes, each one done or not by
* what the store records of them: two-factor on their `db.members` row, a
* task assigned to them in `db.tasks`, a post of theirs in `db.posts`, a
* file of theirs in `db.files`, an invitation they sent in
* `db.invitations`. Nothing is ticked by hand: a step is done because the
* thing it asks for has happened. "Now" is `REFERENCE_DATE`.
*
* Selectors only, reading `db` inside functions and run on the server.
*/
import { REFERENCE_DATE, db, type Member } from "@/lib/sample-data"
const DAY_MS = 86_400_000
const DAY = new Intl.DateTimeFormat("en-US", { month: "long", day: "numeric", timeZone: "UTC" })
export type SetupStep = {
id: string
title: string
description: string
done: boolean
/** Where the step is done. */
href: string
/** What the button says when this is the next step. */
action: string
}
export type Newcomer = { name: string; first: string; days: number; joined: string }
function newest(): Member | undefined {
return db.members
.all()
.filter((member) => member.status === "active")
.sort((a, b) => b.joinedAt.getTime() - a.joinedAt.getTime())[0]
}
/** The newest active member: who they are, and how long ago they joined; null with nobody to welcome. */
export function newcomer(): Newcomer | null {
const member = newest()
if (!member) return null
return {
name: member.name,
first: member.name.split(" ")[0],
days: Math.floor((REFERENCE_DATE.getTime() - member.joinedAt.getTime()) / DAY_MS),
joined: DAY.format(member.joinedAt),
}
}
/** The first steps, in the order a member takes them, each done by what the store records; none with nobody to welcome. */
export function setupSteps(): SetupStep[] {
const member = newest()
if (!member) return []
return [
{
id: "join",
title: "Join the workspace",
description: "Accept the invitation and choose how you sign in.",
done: member.status === "active",
href: "/people/directory",
action: "Open the directory",
},
{
id: "two-factor",
title: "Turn on two-factor authentication",
description: "A code from your phone as well as your password.",
done: member.twoFactor,
href: "/settings/security",
action: "Turn on two-factor",
},
{
id: "task",
title: "Pick up your first task",
description: "Take something from the board and make it yours.",
done: db.tasks.all().some((task) => task.assignee === member.id),
href: "/projects/tasks",
action: "Open the board",
},
{
id: "hello",
title: "Say hello on the team feed",
description: "One line about who you are and what you work on.",
done: db.posts.all().some((post) => post.author.name === member.name),
href: "/projects/social",
action: "Write a post",
},
{
id: "file",
title: "Put a file in the drive",
description: "Share a document the team can find again.",
done: db.files.all().some((file) => file.ownerId === member.id && file.kind !== "folder"),
href: "/projects/files",
action: "Open the drive",
},
{
id: "invite",
title: "Invite a teammate",
description: "Bring in someone you work with.",
done: db.invitations.all().some((invitation) => invitation.invitedBy === member.id),
href: "/people/invitations",
action: "Invite a teammate",
},
]
}npx shadcn@latest add @vibra/welcome-cards-03Greeting with what is new
welcome-cards-04Good afternoon and the day, the newest release with its notes tagged new, improved or fixed, one button to read them, and a way to put the panel away until the next release.
import * as React from "react"
import Link from "next/link"
import { buttonVariants } from "@/components/ui/button"
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
import { EmptyState } from "@/components/ui/empty-state"
import { StatusBadge, type StatusVariant } from "@/components/ui/status-badge"
import { WhatsNewPanel } from "./welcome-cards-04.client"
import { CHANGELOG_HREF, greeting, latestRelease, type WhatsNew } from "./welcome-cards-04.data"
/** Each kind of line in the tone the kit's changelog gives it; the word is always said too. */
const TAGS: Record<WhatsNew["entries"][number]["tag"], { label: string; variant: StatusVariant }> = {
new: { label: "New", variant: "success" },
improved: { label: "Improved", variant: "info" },
fixed: { label: "Fixed", variant: "warning" },
removed: { label: "Removed", variant: "neutral" },
}
/** The release: when it shipped, its title and summary, and its notes line by line. */
function Release({ release, titleId }: { release: WhatsNew; titleId: string }) {
return (
<>
<p className="type-eyebrow tabular-nums">
New in <span className="font-mono">{release.version}</span> · released{" "}
<time dateTime={release.iso}>{release.date}</time>, {release.ago}
</p>
<div className="flex flex-col gap-1.5">
<h3 id={titleId} className="type-display text-xl text-balance">
{release.title}
</h3>
<p className="text-sm text-pretty text-muted-foreground">{release.summary}</p>
</div>
<ul aria-labelledby={titleId} className="flex flex-col gap-2.5">
{release.entries.map((entry, index) => (
<li key={index} className="flex items-start gap-3 text-sm">
<StatusBadge status={entry.tag} variant={TAGS[entry.tag].variant} label={TAGS[entry.tag].label} size="sm" className="mt-0.5 w-18 shrink-0 justify-center" />
<span className="min-w-0 text-pretty">{entry.text}</span>
</li>
))}
</ul>
</>
)
}
/**
* The greeting at the top of a dashboard, with what changed in the product:
* good afternoon and the day in the strip; on the sheet the newest release —
* its version, when it shipped, its title and summary, and its notes line by
* line, each tagged new, improved, fixed or removed — and beside it the one
* thing to do: read the notes. The reader can put the panel away until the
* next release, and bring it back.
*
* A server component: it reads through `./welcome-cards-04.data`, the time
* of day and every date written from `REFERENCE_DATE` in UTC, and hands the
* island the release already drawn.
*/
export function WelcomeCards04() {
const titleId = React.useId()
const releaseId = React.useId()
const { hello, today } = greeting()
const release = latestRelease()
return (
<section
data-section="welcome-cards-04"
data-slot="section"
aria-labelledby={titleId}
className="w-full bg-background p-4 text-foreground md:p-6"
>
<div className="mx-auto max-w-7xl">
<Card>
<CardHeader>
<CardTitle>
<h2 id={titleId}>{hello}</h2>
</CardTitle>
<CardDescription>{today}</CardDescription>
</CardHeader>
{release ? (
<WhatsNewPanel body={<Release release={release} titleId={releaseId} />} version={release.version} href={release.href} />
) : (
<CardContent>
<EmptyState
size="sm"
title="Nothing new yet"
description="The notes for each release land here the day it ships."
action={
<Link href={CHANGELOG_HREF} className={buttonVariants({ variant: "outline", size: "sm" })}>
Open the changelog
</Link>
}
/>
</CardContent>
)}
</Card>
</div>
</section>
)
}
export default WelcomeCards04"use client"
import * as React from "react"
import Link from "next/link"
import { ArrowRightIcon } from "lucide-react"
import { cn } from "@/lib/utils"
import { Button, buttonVariants } from "@/components/ui/button"
import { CardContent } from "@/components/ui/card"
export type WhatsNewPanelProps = {
/** The release — its version, date, title, summary and notes — drawn on the server. */
body: React.ReactNode
version: string
/** The release's place in the changelog. */
href: string
}
/**
* The release panel and the one choice a reader makes about it: read the
* notes, or put the panel away until the next release. Put away, the panel
* folds to one line that says where the release still is, with a button to
* bring it back; the focus moves to whichever button is left, and a status
* line says what happened. Hiding is the island's own state: keep the
* version a reader dismissed wherever you keep their settings.
*/
export function WhatsNewPanel({ body, version, href }: WhatsNewPanelProps) {
const [hidden, setHidden] = React.useState(false)
const [message, setMessage] = React.useState("")
const hide = React.useRef<HTMLButtonElement>(null)
const show = React.useRef<HTMLButtonElement>(null)
const moved = React.useRef(false)
React.useEffect(() => {
// Only after a press: the first render leaves the focus where it was.
if (!moved.current) return
;(hidden ? show : hide).current?.focus()
}, [hidden])
function toggle(next: boolean) {
moved.current = true
setHidden(next)
setMessage(next ? `Hidden until the next release. ${version} stays in the changelog.` : `Showing what is new in ${version} again.`)
}
return (
<>
{hidden ? (
<CardContent className="flex flex-wrap items-center justify-between gap-3">
<p className="text-sm text-muted-foreground">
You will see the next release here. {version} is in the{" "}
<Link href={href} className="rounded-sm text-foreground underline underline-offset-4 focus-ring">
changelog
</Link>
.
</p>
<Button ref={show} type="button" variant="outline" size="sm" onClick={() => toggle(false)}>
Show {version} again
</Button>
</CardContent>
) : (
<CardContent className="p-0">
<div className="grid lg:grid-cols-[minmax(0,1fr)_18rem]">
<div className="flex min-w-0 flex-col gap-4 p-5 md:p-6">{body}</div>
<div className="flex flex-col gap-2 border-t border-border p-5 md:p-6 lg:border-t-0 lg:border-s">
<p className="type-eyebrow">Next</p>
<Link href={href} className={cn(buttonVariants(), "w-full justify-between")}>
Read the {version} notes
<ArrowRightIcon aria-hidden="true" data-icon="inline-end" />
</Link>
<Button ref={hide} type="button" variant="ghost" size="sm" onClick={() => toggle(true)} className="w-full">
Hide until the next release
</Button>
</div>
</div>
</CardContent>
)}
{/* Always mounted, so each change is read out rather than a node appearing. */}
<p role="status" aria-live="polite" className="sr-only">
{message}
</p>
</>
)
}/**
* What `welcome-cards-04` reads: the greeting at the top of the dashboard —
* who is reading and what part of the day it is — with what changed in the
* product lately: the newest stable release in `db.releases` shipped on or
* before `REFERENCE_DATE`, its title, summary and every line of its notes.
* The greeting — the reader's first name and the part of the day — is
* `team-projects`' own. Every date is written here in UTC.
*
* Selectors only, reading `db` inside functions and run on the server.
*/
import { greeting } from "@/lib/dashboards/projects/team"
import { REFERENCE_DATE, db, type ReleaseTag } from "@/lib/sample-data"
export { greeting }
/** The Support dashboard's changelog. */
export const CHANGELOG_HREF = "/support/changelog"
const DAY_MS = 86_400_000
const DAY = new Intl.DateTimeFormat("en-US", { month: "short", day: "numeric", timeZone: "UTC" })
export type WhatsNew = {
version: string
title: string
summary: string
iso: string
/** "Aug 31", in UTC. */
date: string
/** How long ago it shipped, in words: "today", "yesterday", "4 days ago". */
ago: string
entries: { tag: ReleaseTag; text: string }[]
/** The release's place in the changelog. */
href: string
}
/** The newest stable release shipped on or before the reference date; null before the first. */
export function latestRelease(): WhatsNew | null {
const release = db.releases
.all()
.filter((row) => row.channel === "stable" && row.date.getTime() <= REFERENCE_DATE.getTime())
.sort((a, b) => b.date.getTime() - a.date.getTime())[0]
if (!release) return null
const today = Date.UTC(REFERENCE_DATE.getUTCFullYear(), REFERENCE_DATE.getUTCMonth(), REFERENCE_DATE.getUTCDate())
const days = Math.round((today - release.date.getTime()) / DAY_MS)
return {
version: release.version,
title: release.title,
summary: release.summary,
iso: release.date.toISOString(),
date: DAY.format(release.date),
ago: days === 0 ? "today" : days === 1 ? "yesterday" : `${days} days ago`,
entries: release.entries.map((entry) => ({ tag: entry.tag, text: entry.text })),
href: `${CHANGELOG_HREF}#v${release.version}`,
}
}npx shadcn@latest add @vibra/welcome-cards-04