From 94e4453d487df6ecc61644dd781abd6569bc795e Mon Sep 17 00:00:00 2001 From: Haydn Ewers <27211+haydn@users.noreply.github.com> Date: Thu, 28 Aug 2025 14:38:02 +1000 Subject: [PATCH] add new badges to items --- compose.yml | 5 +++ package.json | 3 +- pnpm-lock.yaml | 79 +++++++++++++++++++++++++++++++++++++++++--- src/app/List.tsx | 44 +++++++++++++++++------- src/app/api/route.ts | 55 +++++++++++++++++++++++++++--- src/app/index.ts | 6 +++- 6 files changed, 169 insertions(+), 23 deletions(-) create mode 100644 compose.yml diff --git a/compose.yml b/compose.yml new file mode 100644 index 0000000..e738f91 --- /dev/null +++ b/compose.yml @@ -0,0 +1,5 @@ +services: + redis: + image: redis + ports: + - "6379:6379" diff --git a/package.json b/package.json index c4ff91f..23a4d39 100644 --- a/package.json +++ b/package.json @@ -1,11 +1,12 @@ { "dependencies": { - "@colonydb/anthill": "0.2.0-26", + "@colonydb/anthill": "0.2.0-28", "@vercel/analytics": "^1.5.0", "jsdom": "^26.0.0", "next": "15.2.1", "react": "^19.0.0", "react-dom": "^19.0.0", + "redis": "^5.8.2", "swr": "^2.3.3", "temporal-polyfill": "^0.3.0", "uuid": "^11.1.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 3bef106..9073f2b 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -9,8 +9,8 @@ importers: .: dependencies: '@colonydb/anthill': - specifier: 0.2.0-26 - version: 0.2.0-26(@types/react@19.1.9)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)(valibot@1.1.0(typescript@5.9.2)) + specifier: 0.2.0-28 + version: 0.2.0-28(@types/react@19.1.9)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)(valibot@1.1.0(typescript@5.9.2)) '@vercel/analytics': specifier: ^1.5.0 version: 1.5.0(next@15.2.1(react-dom@19.1.1(react@19.1.1))(react@19.1.1))(react@19.1.1) @@ -26,6 +26,9 @@ importers: react-dom: specifier: ^19.0.0 version: 19.1.1(react@19.1.1) + redis: + specifier: ^5.8.2 + version: 5.8.2 swr: specifier: ^2.3.3 version: 2.3.5(react@19.1.1) @@ -119,8 +122,8 @@ packages: cpu: [x64] os: [win32] - '@colonydb/anthill@0.2.0-26': - resolution: {integrity: sha512-DzQ1D4F+k1mkKmZKCWEvJc1k9y3UTbhFo5FTb+kMf7k0v8hg0UDtcV1XhE4ymcq0/TCLBIPYmFdHWMzTcbNEsg==} + '@colonydb/anthill@0.2.0-28': + resolution: {integrity: sha512-WF7Cx6agsHm58WwURTAt2K2THqQ+L2MWH2fYRWsAwN90aWHv2dxth2loAtvzhlTHyFR/07vCbrinjYj/x/nD1w==} peerDependencies: react: ^19.0.0 react-dom: ^19.0.0 @@ -316,6 +319,34 @@ packages: cpu: [x64] os: [win32] + '@redis/bloom@5.8.2': + resolution: {integrity: sha512-855DR0ChetZLarblio5eM0yLwxA9Dqq50t8StXKp5bAtLT0G+rZ+eRzzqxl37sPqQKjUudSYypz55o6nNhbz0A==} + engines: {node: '>= 18'} + peerDependencies: + '@redis/client': ^5.8.2 + + '@redis/client@5.8.2': + resolution: {integrity: sha512-WtMScno3+eBpTac1Uav2zugXEoXqaU23YznwvFgkPwBQVwEHTDgOG7uEAObtZ/Nyn8SmAMbqkEubJaMOvnqdsQ==} + engines: {node: '>= 18'} + + '@redis/json@5.8.2': + resolution: {integrity: sha512-uxpVfas3I0LccBX9rIfDgJ0dBrUa3+0Gc8sEwmQQH0vHi7C1Rx1Qn8Nv1QWz5bohoeIXMICFZRcyDONvum2l/w==} + engines: {node: '>= 18'} + peerDependencies: + '@redis/client': ^5.8.2 + + '@redis/search@5.8.2': + resolution: {integrity: sha512-cNv7HlgayavCBXqPXgaS97DRPVWFznuzsAmmuemi2TMCx5scwLiP50TeZvUS06h/MG96YNPe6A0Zt57yayfxwA==} + engines: {node: '>= 18'} + peerDependencies: + '@redis/client': ^5.8.2 + + '@redis/time-series@5.8.2': + resolution: {integrity: sha512-g2NlHM07fK8H4k+613NBsk3y70R2JIM2dPMSkhIjl2Z17SYvaYKdusz85d7VYOrZBWtDrHV/WD2E3vGu+zni8A==} + engines: {node: '>= 18'} + peerDependencies: + '@redis/client': ^5.8.2 + '@swc/counter@0.1.3': resolution: {integrity: sha512-e2BR4lsJkkRlKZ/qCHPw9ZaSxc0MVUd7gtbtaB7aMvHeJVYe8sOB8DBZkP2DtISHGSku9sCK6T6cnY0CtXrOCQ==} @@ -434,6 +465,10 @@ packages: resolution: {integrity: sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==} engines: {node: '>=6'} + cluster-key-slot@1.1.2: + resolution: {integrity: sha512-RMr0FhtfXemyinomL4hrWcYJxmX6deFdCxpJzhDttxgO1+bcCnkk+9drydLVDmAMG7NE6aN/fl4F7ucU/90gAA==} + engines: {node: '>=0.10.0'} + color-convert@2.0.1: resolution: {integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==} engines: {node: '>=7.0.0'} @@ -731,6 +766,10 @@ packages: resolution: {integrity: sha512-w8nqGImo45dmMIfljjMwOGtbmC/mk4CMYhWIicdSflH91J9TyCyczcPFXJzrZ/ZXcgGRFeP6BU0BEJTw6tZdfQ==} engines: {node: '>=0.10.0'} + redis@5.8.2: + resolution: {integrity: sha512-31vunZj07++Y1vcFGcnNWEf5jPoTkGARgfWI4+Tk55vdwHxhAvug8VEtW7Cx+/h47NuJTEg/JL77zAwC6E0OeA==} + engines: {node: '>= 18'} + refractor@5.0.0: resolution: {integrity: sha512-QXOrHQF5jOpjjLfiNk5GFnWhRXvxjUVnlFxkeDmewR5sXkr3iM46Zo+CnRR8B+MDVqkULW4EcLVcRBNOPXHosw==} @@ -972,7 +1011,7 @@ snapshots: '@biomejs/cli-win32-x64@2.1.4': optional: true - '@colonydb/anthill@0.2.0-26(@types/react@19.1.9)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)(valibot@1.1.0(typescript@5.9.2))': + '@colonydb/anthill@0.2.0-28(@types/react@19.1.9)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)(valibot@1.1.0(typescript@5.9.2))': dependencies: '@haydn/universal': 0.0.4 clsx: 2.1.1 @@ -1116,6 +1155,26 @@ snapshots: '@next/swc-win32-x64-msvc@15.2.1': optional: true + '@redis/bloom@5.8.2(@redis/client@5.8.2)': + dependencies: + '@redis/client': 5.8.2 + + '@redis/client@5.8.2': + dependencies: + cluster-key-slot: 1.1.2 + + '@redis/json@5.8.2(@redis/client@5.8.2)': + dependencies: + '@redis/client': 5.8.2 + + '@redis/search@5.8.2(@redis/client@5.8.2)': + dependencies: + '@redis/client': 5.8.2 + + '@redis/time-series@5.8.2(@redis/client@5.8.2)': + dependencies: + '@redis/client': 5.8.2 + '@swc/counter@0.1.3': {} '@swc/helpers@0.5.15': @@ -1201,6 +1260,8 @@ snapshots: clsx@2.1.1: {} + cluster-key-slot@1.1.2: {} + color-convert@2.0.1: dependencies: color-name: 1.1.4 @@ -1677,6 +1738,14 @@ snapshots: react@19.1.1: {} + redis@5.8.2: + dependencies: + '@redis/bloom': 5.8.2(@redis/client@5.8.2) + '@redis/client': 5.8.2 + '@redis/json': 5.8.2(@redis/client@5.8.2) + '@redis/search': 5.8.2(@redis/client@5.8.2) + '@redis/time-series': 5.8.2(@redis/client@5.8.2) + refractor@5.0.0: dependencies: '@types/hast': 3.0.4 diff --git a/src/app/List.tsx b/src/app/List.tsx index 36fd430..8a99a8b 100644 --- a/src/app/List.tsx +++ b/src/app/List.tsx @@ -1,11 +1,11 @@ "use client"; +import { Badge } from "@colonydb/anthill/Badge"; import { CodeBlock } from "@colonydb/anthill/CodeBlock"; import { Header } from "@colonydb/anthill/Header"; import { Heading } from "@colonydb/anthill/Heading"; import { Link } from "@colonydb/anthill/Link"; import { PlainText } from "@colonydb/anthill/PlainText"; -import { RichText } from "@colonydb/anthill/RichText"; import { Section } from "@colonydb/anthill/Section"; import { Stack } from "@colonydb/anthill/Stack"; import { type ReactNode, useEffect, useState } from "react"; @@ -73,11 +73,10 @@ const List = ({ actions={actions} description={ data ? ( - - Updated:{" "} + <PlainText font="tiny" color={["gray-s1", "gray-t1"]}> {Temporal.Instant.from(data?.fetchedAt).toLocaleString(undefined, { + dateStyle: "short", timeStyle: "long", - dateStyle: "medium", })} </PlainText> ) : null @@ -105,14 +104,35 @@ const List = ({ ) : null} </Stack> ) : ( - <RichText> - <ul> - {data.items.map(({ title, url }, index) => { - const key = `${title}:${url}:${index}`; - return <li key={key}>{url ? <a href={url}>{title}</a> : title}</li>; - })} - </ul> - </RichText> + <Stack tagName="ul"> + {data.items.map(({ firstSeen, title, url: itemUrl }, index) => { + const key = `${title}:${itemUrl}:${index}`; + return ( + <li key={key} style={{ breakInside: "avoid" }}> + <div> + {Temporal.Now.instant() + .since(Temporal.Instant.from(firstSeen)) + .round({ roundingMode: "trunc", smallestUnit: "days" }).days < 2 ? ( + <> + <Badge hue="lime">New</Badge>{" "} + </> + ) : null} + {itemUrl ? <Link href={itemUrl}>{title}</Link> : title} + </div> + <div> + <PlainText font="tiny" color={["gray-s1", "gray-t1"]}> + <Link href={url}>{name}</Link> + {" • "} + {Temporal.Instant.from(firstSeen).toLocaleString(undefined, { + dateStyle: "short", + timeStyle: "long", + })} + </PlainText> + </div> + </li> + ); + })} + </Stack> )} </Section> ); diff --git a/src/app/api/route.ts b/src/app/api/route.ts index e92a320..fa92c02 100644 --- a/src/app/api/route.ts +++ b/src/app/api/route.ts @@ -1,8 +1,18 @@ import { JSDOM, VirtualConsole } from "jsdom"; +import { after } from "next/server"; +import { createClient } from "redis"; import { toTemporalInstant } from "temporal-polyfill"; import type { ListResult } from ".."; export const GET = async (request: Request) => { + const redis = process.env.REDIS_URL ? createClient({ url: process.env.REDIS_URL }) : null; + + if (redis) await redis.connect(); + + after(() => { + if (redis) redis.destroy(); + }); + const requestUrl = URL.parse(request.url); const include = requestUrl?.searchParams.get("include"); @@ -28,9 +38,11 @@ export const GET = async (request: Request) => { if (response.ok === false) throw Error("Failed to fetch URL"); - const dateHeader = response.headers.get("date"); + const sourceDateHeader = response.headers.get("date"); - const instant = toTemporalInstant.apply(dateHeader ? new Date(dateHeader) : new Date(Date.now())); + const sourceUpdatedInstant = toTemporalInstant.apply( + sourceDateHeader ? new Date(sourceDateHeader) : new Date(Date.now()), + ); const html = await response.text(); @@ -49,7 +61,7 @@ export const GET = async (request: Request) => { itemsAfterFilter: 0, itemsFound: items.length, }, - fetchedAt: instant.toString(), + fetchedAt: sourceUpdatedInstant.toString(), items: [], }; @@ -88,7 +100,42 @@ export const GET = async (request: Request) => { result.debug.firstLink = parsedUrl; } - result.items.push({ title, url: parsedUrl }); + result.items.push({ + firstSeen: sourceUpdatedInstant.toString(), + title, + url: parsedUrl, + }); + } + } + + if (redis) { + const keys = result.items.map( + ({ title }) => `${url}:${itemSelector}:${titleSelector ?? ""}:${title.slice(0, 100)}`, + ); + + if (keys.length > 0) { + const firstSeenValues = await redis.HMGET("first_seen", keys); + + for (let i = 0; i < firstSeenValues.length; i++) { + const value = firstSeenValues[i]; + const item = result.items[i]; + + if (item && value) item.firstSeen = value; + } + + await redis + .multi() + .HSET( + "first_seen", + Object.fromEntries( + keys.map((key, index) => [ + key, + firstSeenValues[index] ?? sourceUpdatedInstant.toString(), + ]), + ), + ) + .HEXPIRE("first_seen", keys, 60 * 60 * 24 * 28) + .exec(); } } diff --git a/src/app/index.ts b/src/app/index.ts index 98c60fb..e9d73e6 100644 --- a/src/app/index.ts +++ b/src/app/index.ts @@ -17,5 +17,9 @@ export type ListResult = { firstLink: string | undefined; }; fetchedAt: string; - items: Array<{ title: string; url: string | undefined }>; + items: Array<{ + firstSeen: string; + title: string; + url: string | undefined; + }>; }; -- 2.51.2