grok-digest 0.1.1 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,18 @@
1
+ export declare const REVIEW_SIZE = 5;
2
+ export type ReviewPick = {
3
+ id: string;
4
+ reason: string;
5
+ };
6
+ type Saved = {
7
+ id: string;
8
+ savedAt: Date;
9
+ shelfLife: "news" | "evergreen";
10
+ };
11
+ export declare function localDay(date: Date, timeZone?: string): string;
12
+ export declare function dailyReview({ bookmarks, done, today, timeZone, }: {
13
+ bookmarks: readonly Saved[];
14
+ done: ReadonlySet<string>;
15
+ today: string;
16
+ timeZone?: string;
17
+ }): ReviewPick[];
18
+ export {};
@@ -0,0 +1,57 @@
1
+ export const REVIEW_SIZE = 5;
2
+ const REVIEW_AGES = new Map([
3
+ [1, "Saved yesterday"],
4
+ [7, "Saved a week ago"],
5
+ [30, "Saved a month ago"],
6
+ [90, "Saved 3 months ago"],
7
+ ]);
8
+ const NEWS_AGES = new Set([1, 7]);
9
+ const BACKLOG_REASON = "From your backlog";
10
+ const DAY_MS = 86_400_000;
11
+ export function localDay(date, timeZone) {
12
+ return new Intl.DateTimeFormat("en-CA", { timeZone, year: "numeric", month: "2-digit", day: "2-digit" }).format(date);
13
+ }
14
+ function daysBetween(from, to) {
15
+ return Math.round((Date.parse(to) - Date.parse(from)) / DAY_MS);
16
+ }
17
+ function anniversaryReason(saved, today) {
18
+ const years = Number(today.slice(0, 4)) - Number(saved.slice(0, 4));
19
+ if (years < 1 || saved.slice(5) !== today.slice(5))
20
+ return undefined;
21
+ return years === 1 ? "Saved a year ago today" : `Saved ${years} years ago today`;
22
+ }
23
+ function hash(text) {
24
+ let value = 0x811c9dc5;
25
+ for (const char of text) {
26
+ value ^= char.codePointAt(0) ?? 0;
27
+ value = Math.imul(value, 0x01000193) >>> 0;
28
+ }
29
+ return value;
30
+ }
31
+ export function dailyReview({ bookmarks, done, today, timeZone, }) {
32
+ const candidates = bookmarks
33
+ .filter(({ id }) => !done.has(id))
34
+ .map(({ id, savedAt, shelfLife }) => {
35
+ const saved = localDay(savedAt, timeZone);
36
+ const age = daysBetween(saved, today);
37
+ const news = shelfLife === "news";
38
+ const reason = news
39
+ ? NEWS_AGES.has(age)
40
+ ? REVIEW_AGES.get(age)
41
+ : undefined
42
+ : (REVIEW_AGES.get(age) ?? anniversaryReason(saved, today));
43
+ return { id, age, news, reason };
44
+ })
45
+ .filter(({ age }) => age >= 1);
46
+ const due = candidates
47
+ .flatMap(({ id, age, reason }) => (reason === undefined ? [] : [{ id, age, reason }]))
48
+ .toSorted((a, b) => b.age - a.age)
49
+ .slice(0, REVIEW_SIZE)
50
+ .map(({ id, reason }) => ({ id, reason }));
51
+ const backlog = candidates
52
+ .filter(({ news, reason }) => !news && reason === undefined)
53
+ .toSorted((a, b) => hash(`${today}:${a.id}`) - hash(`${today}:${b.id}`))
54
+ .slice(0, REVIEW_SIZE - due.length)
55
+ .map(({ id }) => ({ id, reason: BACKLOG_REASON }));
56
+ return [...due, ...backlog];
57
+ }
@@ -1,13 +1,20 @@
1
1
  import type { Bookmark } from "./bookmarks.ts";
2
+ import type { Filter } from "./filter.ts";
2
3
  import { type Topic } from "./topics.ts";
3
4
  export type SearchEntry = {
4
5
  id: string;
5
6
  topic: Topic;
7
+ savedAt: Date;
8
+ shelfLife: Bookmark["shelfLife"];
6
9
  text: string;
7
10
  };
8
11
  export declare function toSearchEntry(bookmark: Bookmark): SearchEntry;
9
- export declare function matchingIds({ entries, query, topic, }: {
12
+ export type Triage = {
13
+ done: ReadonlySet<string>;
14
+ review: ReadonlySet<string>;
15
+ };
16
+ export declare function matchingIds({ entries, filter: { query, topic, view }, triage, }: {
10
17
  entries: readonly SearchEntry[];
11
- query: string;
12
- topic: Topic | null;
18
+ filter: Filter;
19
+ triage: Triage;
13
20
  }): Set<string>;
@@ -3,17 +3,35 @@ function normalize(text) {
3
3
  return text.normalize("NFKC").toLowerCase();
4
4
  }
5
5
  export function toSearchEntry(bookmark) {
6
- const { id, topic, author, summary, text, keyPoints } = bookmark;
6
+ const { id, topic, savedAt, shelfLife, author, tldr, summary, text, keyPoints, action, links } = bookmark;
7
7
  return {
8
8
  id,
9
9
  topic,
10
- text: normalize([author.name, `@${author.handle}`, TOPIC_LABELS[topic], summary, text, ...keyPoints].join("\n")),
10
+ savedAt,
11
+ shelfLife,
12
+ text: normalize([
13
+ author.name,
14
+ `@${author.handle}`,
15
+ TOPIC_LABELS[topic],
16
+ tldr,
17
+ summary,
18
+ text,
19
+ ...keyPoints,
20
+ action,
21
+ ...links.map(({ title }) => title),
22
+ ].join("\n")),
11
23
  };
12
24
  }
13
- export function matchingIds({ entries, query, topic, }) {
25
+ const IN_VIEW = {
26
+ today: (id, { review }) => review.has(id),
27
+ inbox: (id, { done }) => !done.has(id),
28
+ done: (id, { done }) => done.has(id),
29
+ all: () => true,
30
+ };
31
+ export function matchingIds({ entries, filter: { query, topic, view }, triage, }) {
14
32
  const terms = normalize(query).split(/\s+/).filter(Boolean);
15
33
  return new Set(entries
16
34
  .filter((entry) => topic === null || entry.topic === topic)
17
- .filter((entry) => terms.every((term) => entry.text.includes(term)))
35
+ .filter((entry) => terms.length === 0 ? IN_VIEW[view](entry.id, triage) : terms.every((term) => entry.text.includes(term)))
18
36
  .map((entry) => entry.id));
19
37
  }
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  import BookmarkCard from "../components/BookmarkCard.astro";
3
- import { BookmarkFilters } from "../components/BookmarkFilters";
3
+ import { BookmarkControls } from "../components/BookmarkControls";
4
4
  import { ThemeToggle } from "../components/ThemeToggle";
5
5
  import Layout from "../layouts/Layout.astro";
6
6
  import { bookmarks } from "../lib/load-bookmarks";
@@ -22,11 +22,19 @@ const designFeedHref = `${base}rss/design.xml`;
22
22
  <ThemeToggle client:load />
23
23
  </header>
24
24
 
25
+ <noscript>
26
+ <style is:inline>
27
+ [data-bookmark-list] {
28
+ visibility: visible !important;
29
+ }
30
+ </style>
31
+ </noscript>
32
+
25
33
  <main class="flex flex-1 flex-col gap-6 pb-16">
26
34
  {bookmarks.length > 0 ? (
27
35
  <>
28
- <BookmarkFilters client:only="react" entries={entries} />
29
- <ul class="flex flex-col gap-4 sm:gap-5">
36
+ <BookmarkControls client:only="react" entries={entries} />
37
+ <ul data-bookmark-list class="flex flex-col gap-4 not-data-ready:invisible sm:gap-5">
30
38
  {bookmarks.map((bookmark) => (
31
39
  <BookmarkCard bookmark={bookmark} />
32
40
  ))}
@@ -39,11 +47,11 @@ const designFeedHref = `${base}rss/design.xml`;
39
47
 
40
48
  <footer class="flex flex-col gap-2 border-t py-8 text-sm text-muted-foreground">
41
49
  <p>
42
- <a class="underline decoration-border underline-offset-4 hover:decoration-current" href={feedHref}>
50
+ <a class="underline decoration-border underline-offset-4 hover:decoration-brand" href={feedHref}>
43
51
  Follow new bookmarks
44
52
  </a>{" "}
45
53
  in a feed reader. Each topic has its own feed, such as{" "}
46
- <a class="underline decoration-border underline-offset-4 hover:decoration-current" href={designFeedHref}>
54
+ <a class="underline decoration-border underline-offset-4 hover:decoration-brand" href={designFeedHref}>
47
55
  Design
48
56
  </a>
49
57
  .
@@ -0,0 +1,10 @@
1
+ export declare const getStaticPaths: () => {
2
+ params: {
3
+ size: string;
4
+ };
5
+ }[];
6
+ export declare const GET: ({ params }: {
7
+ params: {
8
+ size?: string;
9
+ };
10
+ }) => Response;
@@ -0,0 +1,10 @@
1
+ import icon180 from "../assets/icon-180.png?inline";
2
+ import icon192 from "../assets/icon-192.png?inline";
3
+ import icon512 from "../assets/icon-512.png?inline";
4
+ const ICONS = { "180": icon180, "192": icon192, "512": icon512 };
5
+ export const getStaticPaths = () => Object.keys(ICONS).map((size) => ({ params: { size } }));
6
+ export const GET = ({ params }) => {
7
+ const dataUrl = ICONS[params.size ?? ""] ?? "";
8
+ const bytes = Buffer.from(dataUrl.slice(dataUrl.indexOf(",") + 1), "base64");
9
+ return new Response(bytes, { headers: { "Content-Type": "image/png" } });
10
+ };
@@ -0,0 +1 @@
1
+ export declare const GET: () => Response;
@@ -0,0 +1,14 @@
1
+ const manifest = {
2
+ name: "Grok Digest",
3
+ short_name: "Digest",
4
+ start_url: "./",
5
+ scope: "./",
6
+ display: "standalone",
7
+ background_color: "#f9f6f1",
8
+ theme_color: "#f9f6f1",
9
+ icons: [
10
+ { src: "icon-192.png", sizes: "192x192", type: "image/png", purpose: "any maskable" },
11
+ { src: "icon-512.png", sizes: "512x512", type: "image/png", purpose: "any maskable" },
12
+ ],
13
+ };
14
+ export const GET = () => new Response(JSON.stringify(manifest), { headers: { "Content-Type": "application/manifest+json" } });
@@ -1,6 +1,6 @@
1
1
  @import "tailwindcss";
2
2
  @import "tw-animate-css";
3
- @import "shadcn/tailwind.css";
3
+ @import "./shadcn-tailwind.css";
4
4
  @import "@fontsource-variable/geist";
5
5
  @import "@fontsource-variable/source-serif-4";
6
6
 
@@ -30,6 +30,14 @@
30
30
  --color-card: var(--card);
31
31
  --color-foreground: var(--foreground);
32
32
  --color-background: var(--background);
33
+ --color-brand: var(--brand);
34
+ --color-brand-ink: var(--brand-ink);
35
+ --color-brand-soft: var(--brand-soft);
36
+ --color-done: var(--done);
37
+ --color-done-foreground: var(--done-foreground);
38
+ --color-topic: var(--topic);
39
+ --color-topic-soft: var(--topic-soft);
40
+ --color-topic-foreground: var(--topic-foreground);
33
41
  --radius-sm: calc(var(--radius) * 0.6);
34
42
  --radius-md: calc(var(--radius) * 0.8);
35
43
  --radius-lg: var(--radius);
@@ -57,8 +65,13 @@
57
65
  --destructive: oklch(0.577 0.245 27.325);
58
66
  --border: oklch(0.9 0.01 80);
59
67
  --input: oklch(0.88 0.01 80);
60
- --ring: oklch(0.65 0.02 70);
68
+ --ring: oklch(0.76 0.15 70);
61
69
  --radius: 0.625rem;
70
+ --brand: oklch(0.76 0.15 70);
71
+ --brand-ink: oklch(0.5 0.11 60);
72
+ --brand-soft: oklch(0.955 0.04 85);
73
+ --done: oklch(0.52 0.12 155);
74
+ --done-foreground: oklch(0.985 0.004 85);
62
75
  }
63
76
 
64
77
  .dark {
@@ -79,7 +92,48 @@
79
92
  --destructive: oklch(0.704 0.191 22.216);
80
93
  --border: oklch(1 0 0 / 9%);
81
94
  --input: oklch(1 0 0 / 14%);
82
- --ring: oklch(0.6 0.015 70);
95
+ --ring: oklch(0.78 0.14 72);
96
+ --brand: oklch(0.78 0.14 72);
97
+ --brand-ink: oklch(0.82 0.12 78);
98
+ --brand-soft: oklch(0.28 0.04 72);
99
+ --done: oklch(0.76 0.13 155);
100
+ --done-foreground: oklch(0.2 0.008 60);
101
+ }
102
+
103
+ [data-topic] {
104
+ --topic-chroma: 1;
105
+ --topic: oklch(0.5 calc(0.11 * var(--topic-chroma)) var(--topic-hue));
106
+ --topic-soft: oklch(0.95 calc(0.025 * var(--topic-chroma)) var(--topic-hue));
107
+ --topic-foreground: oklch(0.985 0.004 85);
108
+ }
109
+
110
+ .dark [data-topic] {
111
+ --topic: oklch(0.8 calc(0.1 * var(--topic-chroma)) var(--topic-hue));
112
+ --topic-soft: oklch(0.3 calc(0.045 * var(--topic-chroma)) var(--topic-hue));
113
+ --topic-foreground: oklch(0.2 0.008 60);
114
+ }
115
+
116
+ [data-topic="ai-agents"] {
117
+ --topic-hue: 295;
118
+ }
119
+ [data-topic="models-releases"] {
120
+ --topic-hue: 250;
121
+ }
122
+ [data-topic="dev-tools"] {
123
+ --topic-hue: 195;
124
+ }
125
+ [data-topic="startups-business"] {
126
+ --topic-hue: 120;
127
+ }
128
+ [data-topic="japan-tech"] {
129
+ --topic-hue: 20;
130
+ }
131
+ [data-topic="design"] {
132
+ --topic-hue: 340;
133
+ }
134
+ [data-topic="other"] {
135
+ --topic-hue: 70;
136
+ --topic-chroma: 0.15;
83
137
  }
84
138
 
85
139
  @layer base {