mirror of
https://github.com/supermemoryai/supermemory.git
synced 2026-09-09 22:31:10 +00:00
Removes stale promo cards, banners, and the setup modal. Simplifies the onboarding entry so all new workspaces go through one flow.
679 lines
18 KiB
TypeScript
679 lines
18 KiB
TypeScript
"use client"
|
|
|
|
import { $fetch } from "@lib/api"
|
|
import { useAuth } from "@lib/auth-context"
|
|
import { cn } from "@lib/utils"
|
|
import { useQuery } from "@tanstack/react-query"
|
|
import { ArrowRight, Check, FileText, Loader2, UserPlus } from "lucide-react"
|
|
import { useQueryState } from "nuqs"
|
|
import { useSettingsModal } from "@/components/settings/settings-modal"
|
|
import { useBrainTrial } from "@/hooks/use-brain-trial"
|
|
import { useTrialStatus } from "@/hooks/use-trial-status"
|
|
import { dmSans125ClassName } from "@/lib/fonts"
|
|
import { useViewMode } from "@/lib/view-mode-context"
|
|
import {
|
|
AskInSlackCard,
|
|
CONNECT_TOOLS_CARD_ID,
|
|
ConnectToolsCard,
|
|
useConnectionsBoard,
|
|
} from "./connections-board"
|
|
|
|
const BACKEND =
|
|
process.env.NEXT_PUBLIC_BACKEND_URL ?? "https://api.supermemory.ai"
|
|
|
|
const cardStyle = {
|
|
boxShadow:
|
|
"0 2.842px 14.211px 0 rgba(0, 0, 0, 0.25), 0.711px 0.711px 0.711px 0 rgba(255, 255, 255, 0.10) inset",
|
|
}
|
|
|
|
type RecentDoc = {
|
|
id?: string
|
|
title?: string | null
|
|
createdAt?: string | Date | null
|
|
updatedAt?: string | Date | null
|
|
}
|
|
|
|
type RolloutOverview = {
|
|
status: "running" | "done" | "failed"
|
|
discovered: number
|
|
joined: number
|
|
ready: number
|
|
introduced: number
|
|
failed: number
|
|
}
|
|
|
|
type BrainOverview = {
|
|
research: { status: string | null }
|
|
slack: {
|
|
connected: boolean
|
|
teamName: string | null
|
|
rollout: RolloutOverview | null
|
|
}
|
|
connections: { apps: number }
|
|
members: { count: number }
|
|
}
|
|
|
|
function useBrainOverview() {
|
|
const { user, org } = useAuth()
|
|
const enabled = !!user && !!org?.id
|
|
|
|
const docs = useQuery({
|
|
queryKey: ["brain-recents", org?.id],
|
|
queryFn: async () => {
|
|
const res = await $fetch("@post/documents/documents", {
|
|
body: {
|
|
page: 1,
|
|
limit: 6,
|
|
sort: "createdAt",
|
|
order: "desc",
|
|
containerTags: [],
|
|
},
|
|
disableValidation: true,
|
|
})
|
|
if (res.error) throw new Error(res.error?.message)
|
|
return res.data as unknown as {
|
|
documents?: RecentDoc[]
|
|
pagination?: { totalItems?: number }
|
|
}
|
|
},
|
|
staleTime: 60_000,
|
|
enabled,
|
|
})
|
|
|
|
const lastUpdated = useQuery({
|
|
queryKey: ["brain-last-updated", org?.id],
|
|
queryFn: async () => {
|
|
const res = await $fetch("@post/documents/documents", {
|
|
body: {
|
|
page: 1,
|
|
limit: 1,
|
|
sort: "updatedAt",
|
|
order: "desc",
|
|
containerTags: [],
|
|
},
|
|
disableValidation: true,
|
|
})
|
|
if (res.error) return null
|
|
const docs = (res.data as { documents?: RecentDoc[] })?.documents
|
|
return docs?.[0]?.updatedAt ?? null
|
|
},
|
|
staleTime: 60_000,
|
|
enabled,
|
|
})
|
|
|
|
const connectors = useQuery({
|
|
queryKey: ["brain-home", "connectors"],
|
|
queryFn: async () => {
|
|
const res = await $fetch("@post/connections/list", {
|
|
body: { containerTags: [] },
|
|
})
|
|
if (res.error) return [] as Array<{ provider?: string }>
|
|
return (res.data ?? []) as Array<{ provider?: string }>
|
|
},
|
|
staleTime: 30_000,
|
|
enabled,
|
|
})
|
|
|
|
const mcp = useQuery({
|
|
queryKey: ["mcp-status"],
|
|
queryFn: async () => {
|
|
const res = await $fetch("@get/mcp/has-login")
|
|
if (res.error) return false
|
|
return Boolean((res.data as { previousLogin?: boolean })?.previousLogin)
|
|
},
|
|
staleTime: 60_000,
|
|
enabled,
|
|
})
|
|
|
|
const overview = useQuery({
|
|
queryKey: ["brain-overview", org?.id],
|
|
queryFn: async (): Promise<BrainOverview | null> => {
|
|
const res = await fetch(`${BACKEND}/brain/overview`, {
|
|
credentials: "include",
|
|
})
|
|
if (!res.ok) return null
|
|
return (await res.json()) as BrainOverview
|
|
},
|
|
staleTime: 30_000,
|
|
enabled,
|
|
})
|
|
|
|
const memoriesCount = docs.data?.pagination?.totalItems ?? 0
|
|
const slackConnected = overview.data?.slack.connected ?? false
|
|
const appsCount =
|
|
(overview.data?.connections.apps ?? 0) + (connectors.data?.length ?? 0)
|
|
const connectedCount = appsCount + (slackConnected ? 1 : 0)
|
|
|
|
const currentRole = org?.members
|
|
?.find((m) => m.userId === user?.id)
|
|
?.role?.toLowerCase()
|
|
|
|
return {
|
|
loading: docs.isPending,
|
|
recentDocs: docs.data?.documents ?? [],
|
|
lastUpdatedAt: lastUpdated.data ?? null,
|
|
memoriesCount,
|
|
connectedCount,
|
|
membersCount: overview.data?.members.count ?? org?.members?.length ?? 0,
|
|
canInvite: currentRole === "owner" || currentRole === "admin",
|
|
hasSource: connectedCount > 0,
|
|
hasAgent: mcp.data ?? false,
|
|
hasMemory: memoriesCount > 0,
|
|
hasApps: appsCount > 0,
|
|
slackConnected,
|
|
researchStatus: overview.data?.research.status ?? null,
|
|
rollout: overview.data?.slack.rollout ?? null,
|
|
}
|
|
}
|
|
|
|
export function BrainHomeView() {
|
|
const o = useBrainOverview()
|
|
const trial = useBrainTrial()
|
|
const board = useConnectionsBoard()
|
|
// Rows with no reported state (older orgs, pre-Slack) don't count or render.
|
|
const milestones = [
|
|
...(o.researchStatus != null ? [o.researchStatus === "done"] : []),
|
|
o.slackConnected,
|
|
...(o.rollout != null ? [o.rollout.status === "done"] : []),
|
|
o.hasApps,
|
|
o.membersCount > 1,
|
|
o.hasMemory,
|
|
]
|
|
const milestonesDone = milestones.filter(Boolean).length
|
|
const milestonesTotal = milestones.length
|
|
const showTimeline =
|
|
!o.loading && (trial.state !== "none" || milestonesDone < milestonesTotal)
|
|
|
|
return (
|
|
<div className="mx-auto max-w-[1080px] space-y-6">
|
|
<StatsRow
|
|
memories={o.memoriesCount}
|
|
connected={o.connectedCount}
|
|
members={o.membersCount}
|
|
canInvite={o.canInvite}
|
|
setupDone={milestonesDone}
|
|
setupTotal={milestonesTotal}
|
|
lastUpdatedAt={o.lastUpdatedAt}
|
|
/>
|
|
<div className="grid items-start gap-6 lg:grid-cols-[minmax(0,1fr)_340px]">
|
|
<div className="min-w-0 space-y-6">
|
|
{board.showBoard && <ConnectToolsCard board={board} />}
|
|
<RecentMemories docs={o.recentDocs} loading={o.loading} />
|
|
</div>
|
|
<div className="min-w-0 space-y-6">
|
|
{showTimeline && (
|
|
<BrainTimeline
|
|
researchStatus={o.researchStatus}
|
|
slackConnected={o.slackConnected}
|
|
rollout={o.rollout}
|
|
hasApps={o.hasApps}
|
|
invited={o.membersCount > 1}
|
|
hasMemory={o.hasMemory}
|
|
canInvite={o.canInvite}
|
|
toolsCardVisible={board.showBoard}
|
|
/>
|
|
)}
|
|
<AskInSlackCard board={board} />
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
function StatsRow({
|
|
memories,
|
|
connected,
|
|
members,
|
|
canInvite,
|
|
setupDone,
|
|
setupTotal,
|
|
lastUpdatedAt,
|
|
}: {
|
|
memories: number
|
|
connected: number
|
|
members: number
|
|
canInvite: boolean
|
|
setupDone: number
|
|
setupTotal: number
|
|
lastUpdatedAt: string | Date | null
|
|
}) {
|
|
const { openSettings } = useSettingsModal()
|
|
const [, setInvite] = useQueryState("invite")
|
|
|
|
const onInvite = () => {
|
|
setInvite("1")
|
|
openSettings("account")
|
|
}
|
|
|
|
const tiles: {
|
|
label: string
|
|
value: string
|
|
action?: React.ReactNode
|
|
}[] = [
|
|
{ label: "Memories", value: memories.toLocaleString() },
|
|
{ label: "Connected sources", value: String(connected) },
|
|
{
|
|
label: "Active members",
|
|
value: String(members),
|
|
action: canInvite ? (
|
|
<button
|
|
type="button"
|
|
onClick={onInvite}
|
|
className="hidden items-center gap-1 text-[11px] font-medium text-[#737373] transition-colors hover:text-[#fafafa] sm:inline-flex"
|
|
>
|
|
<UserPlus className="size-3" />
|
|
Invite
|
|
</button>
|
|
) : undefined,
|
|
},
|
|
setupDone < setupTotal
|
|
? { label: "Setup", value: `${setupDone}/${setupTotal}` }
|
|
: {
|
|
label: "Last updated",
|
|
value: formatWhen(lastUpdatedAt) || "—",
|
|
},
|
|
]
|
|
return (
|
|
<section
|
|
className="grid grid-cols-4 divide-x divide-white/[0.04] overflow-hidden rounded-[16px] bg-[#1B1F24]"
|
|
style={cardStyle}
|
|
>
|
|
{tiles.map((t, index) => (
|
|
<div
|
|
key={t.label}
|
|
className={cn(
|
|
"relative min-w-0 px-3 py-3 sm:px-5 sm:py-4",
|
|
index === 2 && canInvite && "pr-8 sm:pr-5",
|
|
)}
|
|
>
|
|
<div className="flex min-w-0 items-start justify-between gap-2">
|
|
<p className="min-w-0 truncate text-[8px] font-semibold uppercase leading-tight tracking-[0.08em] text-[#737373] sm:text-[10px] sm:tracking-[0.12em]">
|
|
<MobileStatLabel label={t.label} />
|
|
</p>
|
|
{t.action}
|
|
</div>
|
|
<p
|
|
className={cn(
|
|
"mt-1 text-[17px] font-semibold leading-none tabular-nums text-[#fafafa] sm:mt-1.5 sm:text-[22px]",
|
|
dmSans125ClassName(),
|
|
)}
|
|
>
|
|
{t.value}
|
|
</p>
|
|
{index === 2 && canInvite && (
|
|
<button
|
|
type="button"
|
|
onClick={onInvite}
|
|
aria-label="Invite teammates"
|
|
title="Invite teammates"
|
|
className="absolute right-1.5 bottom-1.5 inline-flex size-6 items-center justify-center rounded-md bg-white/[0.03] text-[#737373] transition-colors hover:bg-white/[0.07] hover:text-[#fafafa] sm:hidden"
|
|
>
|
|
<UserPlus className="size-3" />
|
|
</button>
|
|
)}
|
|
</div>
|
|
))}
|
|
</section>
|
|
)
|
|
}
|
|
|
|
function MobileStatLabel({ label }: { label: string }) {
|
|
const mobile =
|
|
label === "Connected sources"
|
|
? "Sources"
|
|
: label === "Active members"
|
|
? "Members"
|
|
: label
|
|
|
|
return (
|
|
<>
|
|
<span className="sm:hidden">{mobile}</span>
|
|
<span className="hidden sm:inline">{label}</span>
|
|
</>
|
|
)
|
|
}
|
|
|
|
function RecentMemories({
|
|
docs,
|
|
loading,
|
|
}: {
|
|
docs: RecentDoc[]
|
|
loading: boolean
|
|
}) {
|
|
return (
|
|
<section
|
|
className="min-w-0 rounded-[18px] bg-[#1B1F24] p-5"
|
|
style={cardStyle}
|
|
>
|
|
<p
|
|
className={cn(
|
|
"mb-3 text-[15px] font-semibold text-[#fafafa]",
|
|
dmSans125ClassName(),
|
|
)}
|
|
>
|
|
Recent memories
|
|
</p>
|
|
|
|
{loading ? (
|
|
<div className="flex items-center gap-2 py-6 text-[13px] font-medium text-[#737373]">
|
|
<Loader2 className="size-4 animate-spin" />
|
|
Loading…
|
|
</div>
|
|
) : docs.length === 0 ? (
|
|
<div className="flex items-center gap-3 rounded-[12px] bg-[#14161A] px-4 py-5">
|
|
<div className="flex size-9 shrink-0 items-center justify-center rounded-[10px] bg-[#0F1217] text-[#525D6E]">
|
|
<FileText className="size-4" />
|
|
</div>
|
|
<div className="min-w-0">
|
|
<p className="text-[13px] font-medium text-[#fafafa]">
|
|
No memories yet
|
|
</p>
|
|
<p className="mt-0.5 text-[12px] font-medium leading-[1.5] text-[#737373]">
|
|
Connect a source or ask your brain below — what you save shows up
|
|
here.
|
|
</p>
|
|
</div>
|
|
</div>
|
|
) : (
|
|
<ul className="divide-y divide-white/[0.04]">
|
|
{docs.map((doc, i) => (
|
|
<li
|
|
key={doc.id ?? i}
|
|
className="flex items-center gap-3 px-1 py-2.5"
|
|
>
|
|
<div className="flex size-8 shrink-0 items-center justify-center rounded-[8px] bg-[#0F1217] text-[#737373]">
|
|
<FileText className="size-3.5" />
|
|
</div>
|
|
<p className="min-w-0 flex-1 truncate text-[13px] font-medium text-[#fafafa]">
|
|
{doc.title?.trim() || "Untitled memory"}
|
|
</p>
|
|
<span className="shrink-0 text-[11px] font-medium text-[#737373]">
|
|
{formatWhen(doc.createdAt)}
|
|
</span>
|
|
</li>
|
|
))}
|
|
</ul>
|
|
)}
|
|
</section>
|
|
)
|
|
}
|
|
|
|
function TrialStrip() {
|
|
const trial = useBrainTrial()
|
|
const { openSettings } = useSettingsModal()
|
|
|
|
if (trial.state === "ended") {
|
|
return (
|
|
<div className="mb-4 flex items-center justify-between gap-3 rounded-[12px] bg-[#14161A] px-3.5 py-2.5">
|
|
<p className="text-[12px] font-medium text-[#E5735A]">
|
|
Trial ended · your brain is paused
|
|
</p>
|
|
<button
|
|
type="button"
|
|
onClick={() => openSettings("billing")}
|
|
className="shrink-0 rounded-full bg-white px-3 py-1 text-[11px] font-semibold text-[#1D1C1D] transition-opacity hover:opacity-90"
|
|
>
|
|
Activate
|
|
</button>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
if (trial.state !== "trialing" || !trial.startedAtMs || !trial.endsAtMs) {
|
|
return null
|
|
}
|
|
|
|
const DAY_MS = 24 * 60 * 60 * 1000
|
|
const totalDays = Math.max(
|
|
1,
|
|
Math.round((trial.endsAtMs - trial.startedAtMs) / DAY_MS),
|
|
)
|
|
const days = trial.daysRemaining ?? 0
|
|
const dayNum = Math.min(totalDays, Math.max(1, totalDays - days + 1))
|
|
const pct = Math.min(100, Math.max(4, (dayNum / totalDays) * 100))
|
|
|
|
return (
|
|
<button
|
|
type="button"
|
|
onClick={() => openSettings("billing")}
|
|
className="mb-4 block w-full cursor-pointer rounded-[12px] bg-[#14161A] px-3.5 py-2.5 text-left transition-colors hover:bg-[#171A1F]"
|
|
>
|
|
<div className="flex items-center justify-between gap-3">
|
|
<p className="text-[12px] font-medium text-[#fafafa]">
|
|
Free trial ·{" "}
|
|
<span className={cn(days <= 3 ? "text-[#E5A45A]" : "text-[#737373]")}>
|
|
{days} day{days === 1 ? "" : "s"} left
|
|
</span>
|
|
</p>
|
|
<p className="shrink-0 text-[11px] font-medium text-[#525D6E]">
|
|
Ends{" "}
|
|
{new Date(trial.endsAtMs).toLocaleDateString("en-US", {
|
|
month: "short",
|
|
day: "numeric",
|
|
})}
|
|
</p>
|
|
</div>
|
|
<div className="mt-2 h-1 overflow-hidden rounded-full bg-white/[0.06]">
|
|
<div
|
|
className={cn(
|
|
"h-full rounded-full",
|
|
days <= 3 ? "bg-[#E5A45A]" : "bg-[#4BA0FA]",
|
|
)}
|
|
style={{ width: `${pct}%` }}
|
|
/>
|
|
</div>
|
|
</button>
|
|
)
|
|
}
|
|
|
|
function BrainTimeline({
|
|
researchStatus,
|
|
slackConnected,
|
|
rollout,
|
|
hasApps,
|
|
invited,
|
|
hasMemory,
|
|
canInvite,
|
|
toolsCardVisible,
|
|
}: {
|
|
researchStatus: string | null
|
|
slackConnected: boolean
|
|
rollout: RolloutOverview | null
|
|
hasApps: boolean
|
|
invited: boolean
|
|
hasMemory: boolean
|
|
canInvite: boolean
|
|
toolsCardVisible: boolean
|
|
}) {
|
|
const { needsSetup } = useTrialStatus()
|
|
const { openSettings } = useSettingsModal()
|
|
const { setViewMode } = useViewMode()
|
|
const [, setInvite] = useQueryState("invite")
|
|
|
|
const onInvite = () => {
|
|
setInvite("1")
|
|
openSettings("account")
|
|
}
|
|
|
|
const onSetUpApps = () => {
|
|
const card = document.getElementById(CONNECT_TOOLS_CARD_ID)
|
|
if (toolsCardVisible && card) {
|
|
card.scrollIntoView({ behavior: "smooth", block: "center" })
|
|
} else {
|
|
void setViewMode("configure")
|
|
}
|
|
}
|
|
|
|
const researching =
|
|
researchStatus === "queued" || researchStatus === "running"
|
|
type Step = {
|
|
done: boolean
|
|
busy?: boolean
|
|
title: string
|
|
hint?: string
|
|
action?: { label: string; onClick?: () => void; href?: string }
|
|
}
|
|
// Rows with unreported state are omitted rather than shown as never-started.
|
|
const steps: Step[] = [
|
|
...(researchStatus != null
|
|
? [
|
|
{
|
|
done: researchStatus === "done",
|
|
busy: researching,
|
|
title: researching
|
|
? "Researching your company…"
|
|
: "Company research",
|
|
},
|
|
]
|
|
: []),
|
|
{
|
|
done: slackConnected,
|
|
title: slackConnected ? "Slack connected" : "Connect Slack",
|
|
hint: slackConnected
|
|
? undefined
|
|
: needsSetup
|
|
? "Starts with your trial."
|
|
: "Ask your brain from any channel.",
|
|
action:
|
|
slackConnected || needsSetup
|
|
? undefined
|
|
: { label: "Add", href: `${BACKEND}/brain/slack/oauth/install` },
|
|
},
|
|
...(rollout != null
|
|
? [
|
|
{
|
|
done: rollout.status === "done",
|
|
busy: rollout.status === "running",
|
|
title:
|
|
rollout.status === "running"
|
|
? `Learning from channels… ${rollout.ready}/${rollout.discovered} ready`
|
|
: rollout.status === "done"
|
|
? `Learning from channels · ${rollout.ready} ready`
|
|
: "Learning from channels",
|
|
},
|
|
]
|
|
: []),
|
|
{
|
|
done: hasApps,
|
|
title: "Connect apps",
|
|
hint: hasApps ? undefined : "Linear, Notion, GitHub and more.",
|
|
action: hasApps ? undefined : { label: "Set up", onClick: onSetUpApps },
|
|
},
|
|
{
|
|
done: invited,
|
|
title: "Invite teammates",
|
|
hint: invited ? undefined : "Multiply what the brain remembers.",
|
|
action:
|
|
invited || !canInvite
|
|
? undefined
|
|
: { label: "Invite", onClick: onInvite },
|
|
},
|
|
{
|
|
done: hasMemory,
|
|
title: "First memories captured",
|
|
hint: hasMemory ? undefined : "Save a doc, or ask your brain below.",
|
|
},
|
|
]
|
|
|
|
return (
|
|
<section
|
|
className="relative h-fit overflow-hidden rounded-[18px] bg-[#1B1F24] p-5"
|
|
style={cardStyle}
|
|
>
|
|
<div
|
|
aria-hidden
|
|
className="absolute -top-px right-8 left-8 h-px"
|
|
style={{
|
|
background:
|
|
"linear-gradient(to right, transparent, rgba(75,160,250,0.45), transparent)",
|
|
}}
|
|
/>
|
|
<p
|
|
className={cn(
|
|
"text-[15px] font-semibold text-[#fafafa]",
|
|
dmSans125ClassName(),
|
|
)}
|
|
>
|
|
Your Company Brain
|
|
</p>
|
|
<p className="mb-4 mt-0.5 text-[12px] font-medium text-[#737373]">
|
|
How far you've come.
|
|
</p>
|
|
|
|
<TrialStrip />
|
|
|
|
<ul className="space-y-2.5">
|
|
{steps.map((step) => (
|
|
<li key={step.title} className="flex items-start gap-3">
|
|
<span
|
|
aria-hidden
|
|
className={cn(
|
|
"mt-0.5 flex size-[18px] shrink-0 items-center justify-center rounded-full border",
|
|
step.done
|
|
? "border-[#4BA0FA] bg-[#4BA0FA]"
|
|
: "border-[rgba(82,89,102,0.4)]",
|
|
)}
|
|
>
|
|
{step.done ? (
|
|
<Check className="size-3 text-white" />
|
|
) : step.busy ? (
|
|
<Loader2 className="size-3 animate-spin text-[#4BA0FA]" />
|
|
) : null}
|
|
</span>
|
|
<div className="min-w-0 flex-1">
|
|
<div className="flex items-center justify-between gap-2">
|
|
<p
|
|
className={cn(
|
|
"text-[13px] font-medium",
|
|
step.done ? "text-[#737373]" : "text-[#fafafa]",
|
|
)}
|
|
>
|
|
{step.title}
|
|
</p>
|
|
{!step.done &&
|
|
step.action &&
|
|
(step.action.href ? (
|
|
<a
|
|
href={step.action.href}
|
|
className="inline-flex shrink-0 items-center gap-0.5 text-[12px] font-medium text-[#4BA0FA] transition-opacity hover:opacity-80"
|
|
>
|
|
{step.action.label}
|
|
<ArrowRight className="size-3" />
|
|
</a>
|
|
) : (
|
|
<button
|
|
type="button"
|
|
onClick={step.action.onClick}
|
|
className="inline-flex shrink-0 cursor-pointer items-center gap-0.5 text-[12px] font-medium text-[#4BA0FA] transition-opacity hover:opacity-80"
|
|
>
|
|
{step.action.label}
|
|
<ArrowRight className="size-3" />
|
|
</button>
|
|
))}
|
|
</div>
|
|
{!step.done && step.hint && (
|
|
<p className="mt-0.5 text-[12px] font-medium leading-[1.4] text-[#737373]">
|
|
{step.hint}
|
|
</p>
|
|
)}
|
|
</div>
|
|
</li>
|
|
))}
|
|
</ul>
|
|
</section>
|
|
)
|
|
}
|
|
|
|
function formatWhen(value?: string | Date | null): string {
|
|
if (!value) return ""
|
|
const d = new Date(value)
|
|
if (Number.isNaN(d.getTime())) return ""
|
|
const min = Math.round((Date.now() - d.getTime()) / 60000)
|
|
if (min < 1) return "just now"
|
|
if (min < 60) return `${min}m`
|
|
const hr = Math.round(min / 60)
|
|
if (hr < 24) return `${hr}h`
|
|
const day = Math.round(hr / 24)
|
|
if (day < 7) return `${day}d`
|
|
return d.toLocaleDateString()
|
|
}
|