Просмотр исходного кода

feat(stats): split updated timestamp ticker

Adam 3 месяцев назад
Родитель
Сommit
629d5593eb
2 измененных файлов с 153 добавлено и 14 удалено
  1. 62 1
      packages/stats/app/src/routes/index.css
  2. 91 13
      packages/stats/app/src/routes/index.tsx

+ 62 - 1
packages/stats/app/src/routes/index.css

@@ -520,12 +520,73 @@
   flex: 0 0 auto;
 }
 
-[data-page="stats"] [data-slot="hero-meta"] span {
+[data-page="stats"] [data-slot="hero-meta-label"],
+[data-page="stats"] [data-slot="hero-meta-empty"] {
   min-width: 0;
   overflow: hidden;
   text-overflow: ellipsis;
 }
 
+[data-page="stats"] [data-slot="hero-meta-time"] {
+  display: inline-flex;
+  align-items: center;
+  min-width: 0;
+  overflow: hidden;
+}
+
+[data-page="stats"] [data-slot="hero-meta-separator"] {
+  flex: 0 0 auto;
+  margin-right: 0.35em;
+}
+
+[data-page="stats"] [data-slot="hero-meta-ticker"] {
+  position: relative;
+  flex: 0 1 auto;
+  min-width: 0;
+  height: 1.1em;
+  overflow: hidden;
+  line-height: 1.1;
+}
+
+[data-page="stats"] [data-slot="hero-meta-ticker-track"] {
+  display: flex;
+  min-width: 0;
+  flex-direction: column;
+  transform: translateY(0);
+  will-change: transform;
+}
+
+[data-page="stats"] [data-slot="hero-meta-ticker"][data-ticking="true"] [data-slot="hero-meta-ticker-track"] {
+  animation: stats-hero-meta-ticker 680ms cubic-bezier(0.16, 1, 0.3, 1) both;
+}
+
+[data-page="stats"] [data-slot="hero-meta-ticker-item"] {
+  display: block;
+  min-width: 0;
+  overflow: hidden;
+  line-height: 1.1;
+  text-overflow: ellipsis;
+}
+
+@keyframes stats-hero-meta-ticker {
+  0%,
+  18% {
+    transform: translateY(0);
+  }
+
+  82%,
+  100% {
+    transform: translateY(-50%);
+  }
+}
+
+@media (prefers-reduced-motion: reduce) {
+  [data-page="stats"] [data-slot="hero-meta-ticker"][data-ticking="true"] [data-slot="hero-meta-ticker-track"] {
+    animation: none;
+    transform: translateY(-50%);
+  }
+}
+
 @media (min-width: 48rem) {
   [data-page="stats"] [data-section="hero"] {
     gap: 16px;

+ 91 - 13
packages/stats/app/src/routes/index.tsx

@@ -117,11 +117,47 @@ export default function StatsHome() {
 
 function Hero(props: { updatedAt: string | null }) {
   const [timeZone, setTimeZone] = createSignal("UTC")
-  onMount(() => setTimeZone(Intl.DateTimeFormat().resolvedOptions().timeZone || "UTC"))
+  const [previousTimeZone, setPreviousTimeZone] = createSignal("UTC")
+  const [isTicking, setIsTicking] = createSignal(false)
+  const updatedAtParts = (timeZone: string) =>
+    props.updatedAt ? formatUpdatedAtParts(props.updatedAt, timeZone) : { date: "No rows yet", time: "" }
+  const previousUpdatedAt = createMemo(() => updatedAtParts(previousTimeZone()))
+  const currentUpdatedAt = createMemo(() => updatedAtParts(timeZone()))
+  const currentUpdatedLabel = createMemo(() =>
+    props.updatedAt ? `Updated ${formatUpdatedAtLabel(currentUpdatedAt())}` : "No rows yet",
+  )
+  const isDateTicking = createMemo(() => isTicking() && previousUpdatedAt().date !== currentUpdatedAt().date)
+  const isTimeTicking = createMemo(() => isTicking() && previousUpdatedAt().time !== currentUpdatedAt().time)
+
+  onMount(() => {
+    if (!props.updatedAt) return
+    const nextTimeZone = Intl.DateTimeFormat().resolvedOptions().timeZone || "UTC"
+    if (nextTimeZone === "UTC") return
+    if (
+      formatUpdatedAtLabel(formatUpdatedAtParts(props.updatedAt, nextTimeZone)) ===
+      formatUpdatedAtLabel(updatedAtParts("UTC"))
+    )
+      return
+    const timeouts: number[] = []
+    timeouts.push(
+      window.setTimeout(() => {
+        setPreviousTimeZone(timeZone())
+        setTimeZone(nextTimeZone)
+        setIsTicking(true)
+        timeouts.push(
+          window.setTimeout(() => {
+            setPreviousTimeZone(nextTimeZone)
+            setIsTicking(false)
+          }, 720),
+        )
+      }, 480),
+    )
+    onCleanup(() => timeouts.forEach((timeout) => window.clearTimeout(timeout)))
+  })
 
   return (
     <section data-section="hero">
-      <p data-slot="hero-meta">
+      <p data-slot="hero-meta" aria-live="polite" aria-atomic="true" aria-label={currentUpdatedLabel()}>
         <svg aria-hidden="true" width="16" height="16" viewBox="0 0 16 16">
           <path
             fill-rule="evenodd"
@@ -130,7 +166,28 @@ function Hero(props: { updatedAt: string | null }) {
             fill="currentColor"
           />
         </svg>
-        <span>{props.updatedAt ? `Updated ${formatUpdatedAt(props.updatedAt, timeZone())}` : "No rows yet"}</span>
+        {props.updatedAt ? (
+          <>
+            <span data-slot="hero-meta-label" aria-hidden="true">
+              Updated
+            </span>
+            <span data-slot="hero-meta-time" aria-hidden="true">
+              <HeroMetaTickerPart
+                previous={previousUpdatedAt().date}
+                current={currentUpdatedAt().date}
+                ticking={isDateTicking()}
+              />
+              <span data-slot="hero-meta-separator">,</span>
+              <HeroMetaTickerPart
+                previous={previousUpdatedAt().time}
+                current={currentUpdatedAt().time}
+                ticking={isTimeTicking()}
+              />
+            </span>
+          </>
+        ) : (
+          <span data-slot="hero-meta-empty">No rows yet</span>
+        )}
       </p>
       <div data-slot="hero-canvas">
         <div data-slot="hero-pattern" aria-hidden="true" />
@@ -144,6 +201,17 @@ function Hero(props: { updatedAt: string | null }) {
   )
 }
 
+function HeroMetaTickerPart(props: { previous: string; current: string; ticking: boolean }) {
+  return (
+    <span data-slot="hero-meta-ticker" data-ticking={props.ticking}>
+      <span data-slot="hero-meta-ticker-track">
+        <span data-slot="hero-meta-ticker-item">{props.previous}</span>
+        <span data-slot="hero-meta-ticker-item">{props.current}</span>
+      </span>
+    </span>
+  )
+}
+
 function StatsLoading() {
   return (
     <>
@@ -185,17 +253,27 @@ function EmptyState(props: { title: string; description: string }) {
   )
 }
 
-function formatUpdatedAt(value: string, timeZone: string) {
+function formatUpdatedAtParts(value: string, timeZone: string) {
   const date = new Date(value)
-  if (Number.isNaN(date.getTime())) return "just now"
-  return new Intl.DateTimeFormat("en", {
-    month: "short",
-    day: "numeric",
-    hour: "numeric",
-    minute: "2-digit",
-    timeZone,
-    timeZoneName: "short",
-  }).format(date)
+  if (Number.isNaN(date.getTime())) return { date: "just now", time: "" }
+  return {
+    date: new Intl.DateTimeFormat("en", {
+      month: "short",
+      day: "numeric",
+      timeZone,
+    }).format(date),
+    time: new Intl.DateTimeFormat("en", {
+      hour: "numeric",
+      minute: "2-digit",
+      timeZone,
+      timeZoneName: "short",
+    }).format(date),
+  }
+}
+
+function formatUpdatedAtLabel(value: { date: string; time: string }) {
+  if (!value.time) return value.date
+  return `${value.date}, ${value.time}`
 }
 
 function TopModelsSection(props: { data: StatsHomeData["usage"] }) {