claude-plan-viewer 1.3.0 → 1.4.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.
@@ -3,12 +3,12 @@ import type { SortKey, SortDir } from "../types.ts";
3
3
 
4
4
  // Natural default sort direction per column type
5
5
  const SORT_DEFAULTS: Record<SortKey, SortDir> = {
6
- title: "asc", // A → Z
7
- project: "asc", // A → Z
8
- size: "desc", // Biggest first
9
- lines: "desc", // Biggest first
10
- modified: "desc", // Newest first
11
- created: "desc", // Newest first
6
+ title: "asc", // A → Z
7
+ project: "asc", // A → Z
8
+ size: "desc", // Biggest first
9
+ lines: "desc", // Biggest first
10
+ modified: "desc", // Newest first
11
+ created: "desc", // Newest first
12
12
  };
13
13
 
14
14
  interface UseFiltersReturn {
@@ -27,7 +27,7 @@ export function useFilters(): UseFiltersReturn {
27
27
  const [sortKey, setSortKey] = useState<SortKey>("modified");
28
28
  const [sortDir, setSortDir] = useState<SortDir>("desc");
29
29
  const [selectedProjects, setSelectedProjects] = useState<Set<string>>(
30
- new Set()
30
+ new Set(),
31
31
  );
32
32
 
33
33
  const setSort = useCallback((key: SortKey, dir?: SortDir) => {
@@ -0,0 +1,70 @@
1
+ import { type RefObject, useEffect, useRef } from "react";
2
+
3
+ export function useFocusTrap(
4
+ containerRef: RefObject<HTMLElement | null>,
5
+ isOpen: boolean,
6
+ ) {
7
+ const previousFocusRef = useRef<HTMLElement | null>(null);
8
+
9
+ useEffect(() => {
10
+ if (!isOpen || !containerRef.current) return;
11
+
12
+ // Store the previously focused element
13
+ previousFocusRef.current = document.activeElement as HTMLElement;
14
+
15
+ // Get all focusable elements within the container
16
+ const getFocusableElements = () => {
17
+ const container = containerRef.current;
18
+ if (!container) return [];
19
+ return Array.from(
20
+ container.querySelectorAll<HTMLElement>(
21
+ 'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])',
22
+ ),
23
+ ).filter(
24
+ (el) => !el.hasAttribute("disabled") && el.offsetParent !== null,
25
+ );
26
+ };
27
+
28
+ // Focus first element
29
+ const focusableElements = getFocusableElements();
30
+ const firstFocusable = focusableElements[0];
31
+ if (firstFocusable) {
32
+ firstFocusable.focus();
33
+ }
34
+
35
+ // Handle Tab key to trap focus
36
+ const handleKeyDown = (e: KeyboardEvent) => {
37
+ if (e.key !== "Tab") return;
38
+
39
+ const elements = getFocusableElements();
40
+ if (elements.length === 0) return;
41
+
42
+ const firstElement = elements[0];
43
+ const lastElement = elements[elements.length - 1];
44
+
45
+ if (!firstElement || !lastElement) return;
46
+
47
+ if (e.shiftKey) {
48
+ // Shift+Tab: if on first element, go to last
49
+ if (document.activeElement === firstElement) {
50
+ e.preventDefault();
51
+ lastElement.focus();
52
+ }
53
+ } else {
54
+ // Tab: if on last element, go to first
55
+ if (document.activeElement === lastElement) {
56
+ e.preventDefault();
57
+ firstElement.focus();
58
+ }
59
+ }
60
+ };
61
+
62
+ document.addEventListener("keydown", handleKeyDown);
63
+
64
+ return () => {
65
+ document.removeEventListener("keydown", handleKeyDown);
66
+ // Restore focus when modal closes
67
+ previousFocusRef.current?.focus();
68
+ };
69
+ }, [isOpen, containerRef]);
70
+ }
@@ -35,7 +35,7 @@ export function useKeyboard({
35
35
  if (e.key === "ArrowDown" || e.key === "ArrowUp") {
36
36
  e.preventDefault();
37
37
  const idx = plans.findIndex(
38
- (p) => p.filename === selectedPlan?.filename
38
+ (p) => p.filename === selectedPlan?.filename,
39
39
  );
40
40
  let newIdx = e.key === "ArrowDown" ? idx + 1 : idx - 1;
41
41
  if (newIdx < 0) newIdx = 0;
@@ -104,7 +104,7 @@ export function useKeyboard({
104
104
  onToggleHelp,
105
105
  onToggleOverlay,
106
106
  onClearSearch,
107
- ]
107
+ ],
108
108
  );
109
109
 
110
110
  useEffect(() => {
@@ -1,6 +1,7 @@
1
- import { useState, useEffect, useCallback, useRef, useMemo } from "react";
2
- import type { Plan, SortKey, SortDir } from "../types.ts";
3
- import { fetchPlans, fetchPlanContent, refreshCache } from "../utils/api.ts";
1
+ import { useCallback, useRef, useMemo, useState } from "react";
2
+ import useSWR from "swr";
3
+ import type { Plan, SortKey, SortDir, PlanMetadata } from "../types.ts";
4
+ import { fetchPlanContent, refreshCache } from "../utils/api.ts";
4
5
 
5
6
  export interface UsePlansParams {
6
7
  q?: string;
@@ -13,89 +14,76 @@ interface UsePlansReturn {
13
14
  plans: Plan[];
14
15
  total: number;
15
16
  loading: boolean;
17
+ refreshing: boolean;
16
18
  refresh: () => Promise<void>;
17
19
  ensureContent: (plan: Plan) => Promise<Plan>;
18
20
  }
19
21
 
20
- export function usePlans(
21
- params: UsePlansParams = {},
22
- ): UsePlansReturn {
23
- const [allPlans, setAllPlans] = useState<Plan[]>([]);
24
- const [loading, setLoading] = useState(true);
25
- const abortControllerRef = useRef<AbortController | null>(null);
22
+ const plansFetcher = async (url: string): Promise<PlanMetadata[]> => {
23
+ const res = await fetch(url);
24
+ if (!res.ok) {
25
+ throw new Error(`Failed to fetch plans: ${res.statusText}`);
26
+ }
27
+ const data = await res.json();
28
+ return data.plans;
29
+ };
26
30
 
31
+ export function usePlans(params: UsePlansParams = {}): UsePlansReturn {
27
32
  // Cache content by filename to persist across filter changes
28
33
  const contentCache = useRef<Map<string, string>>(new Map());
34
+ const [refreshing, setRefreshing] = useState(false);
35
+
36
+ const {
37
+ data: fetchedPlans,
38
+ isLoading: loading,
39
+ mutate,
40
+ } = useSWR<PlanMetadata[]>("/api/plans", plansFetcher, {
41
+ onErrorRetry: (error, _key, _config, revalidate, { retryCount }) => {
42
+ // Don't retry on 4xx errors
43
+ if (error.status >= 400 && error.status < 500) return;
44
+ // Retry up to 5 times on network errors with exponential backoff
45
+ if (retryCount >= 5) return;
46
+ setTimeout(() => revalidate({ retryCount }), 500 * (retryCount + 1));
47
+ },
48
+ revalidateOnFocus: false,
49
+ });
50
+
51
+ // Merge fetched plans with cached content
52
+ const allPlans = useMemo(() => {
53
+ if (!fetchedPlans) return [];
54
+ return fetchedPlans.map((p) => ({
55
+ ...p,
56
+ content: contentCache.current.get(p.filename),
57
+ }));
58
+ }, [fetchedPlans]);
29
59
 
30
- const loadAllPlans = useCallback(async () => {
31
- // Cancel previous request
32
- if (abortControllerRef.current) {
33
- abortControllerRef.current.abort();
34
- }
35
- abortControllerRef.current = new AbortController();
36
-
60
+ const refresh = useCallback(async () => {
61
+ setRefreshing(true);
37
62
  try {
38
- setLoading(true);
39
- const fetchedPlans = await fetchPlans(abortControllerRef.current.signal);
40
-
41
- // Restore cached content
42
- const plansWithContent = fetchedPlans.map((p) => ({
43
- ...p,
44
- content: contentCache.current.get(p.filename),
45
- }));
46
-
47
- setAllPlans(plansWithContent);
48
- } catch (err) {
49
- if (err instanceof Error && err.name === "AbortError") {
50
- return;
63
+ contentCache.current.clear();
64
+ const { before, after } = await refreshCache();
65
+ // Only reload if count changed
66
+ if (before !== after) {
67
+ await mutate();
51
68
  }
52
- console.error("Failed to load plans:", err);
53
69
  } finally {
54
- setLoading(false);
70
+ setRefreshing(false);
55
71
  }
56
- }, []);
72
+ }, [mutate]);
57
73
 
58
- // Load all plans on mount and when parameters change
59
- useEffect(() => {
60
- loadAllPlans();
61
- return () => {
62
- abortControllerRef.current?.abort();
63
- };
64
- }, [loadAllPlans]);
74
+ const ensureContent = useCallback(async (plan: Plan): Promise<Plan> => {
75
+ if (plan.content) return plan;
65
76
 
66
- const refresh = useCallback(async () => {
67
- contentCache.current.clear();
68
- await refreshCache(); // Invalidate backend cache too
69
- await loadAllPlans();
70
- }, [loadAllPlans]);
71
-
72
- const ensureContent = useCallback(
73
- async (plan: Plan): Promise<Plan> => {
74
- if (plan.content) return plan;
75
-
76
- // Check cache
77
- const cached = contentCache.current.get(plan.filename);
78
- if (cached) {
79
- const updatedPlan = { ...plan, content: cached };
80
- setAllPlans((prev) =>
81
- prev.map((p) => (p.filename === plan.filename ? updatedPlan : p))
82
- );
83
- return updatedPlan;
84
- }
85
-
86
- const content = await fetchPlanContent(plan.filename);
87
- contentCache.current.set(plan.filename, content);
88
- const updatedPlan = { ...plan, content };
89
-
90
- // Update in state
91
- setAllPlans((prev) =>
92
- prev.map((p) => (p.filename === plan.filename ? updatedPlan : p))
93
- );
77
+ // Check cache
78
+ const cached = contentCache.current.get(plan.filename);
79
+ if (cached) {
80
+ return { ...plan, content: cached };
81
+ }
94
82
 
95
- return updatedPlan;
96
- },
97
- []
98
- );
83
+ const content = await fetchPlanContent(plan.filename);
84
+ contentCache.current.set(plan.filename, content);
85
+ return { ...plan, content };
86
+ }, []);
99
87
 
100
88
  // Client-side filtering
101
89
  const filteredPlans = useMemo(() => {
@@ -103,7 +91,7 @@ export function usePlans(
103
91
 
104
92
  if (params.q) {
105
93
  const lowerQ = params.q.toLowerCase();
106
- filtered = filtered.filter(p => {
94
+ filtered = filtered.filter((p) => {
107
95
  const content = contentCache.current.get(p.filename) || "";
108
96
  return (
109
97
  p.title.toLowerCase().includes(lowerQ) ||
@@ -116,7 +104,9 @@ export function usePlans(
116
104
 
117
105
  if (params.projects && params.projects.length > 0) {
118
106
  const projectFilterSet = new Set(params.projects);
119
- filtered = filtered.filter(p => p.project && projectFilterSet.has(p.project));
107
+ filtered = filtered.filter(
108
+ (p) => p.project && projectFilterSet.has(p.project),
109
+ );
120
110
  }
121
111
 
122
112
  return filtered;
@@ -163,6 +153,7 @@ export function usePlans(
163
153
  plans: sortedPlans,
164
154
  total: sortedPlans.length,
165
155
  loading,
156
+ refreshing,
166
157
  refresh,
167
158
  ensureContent,
168
159
  };
@@ -1,21 +1,34 @@
1
- import { useState, useEffect } from "react";
2
- import { fetchProjects } from "../utils/api.ts";
1
+ import useSWR from "swr";
3
2
 
4
3
  interface UseProjectsReturn {
5
4
  projects: string[];
6
5
  loading: boolean;
7
6
  }
8
7
 
9
- export function useProjects(): UseProjectsReturn {
10
- const [projects, setProjects] = useState<string[]>([]);
11
- const [loading, setLoading] = useState(true);
8
+ const projectsFetcher = async (url: string): Promise<string[]> => {
9
+ const res = await fetch(url);
10
+ if (!res.ok) {
11
+ throw new Error(`Failed to fetch projects: ${res.statusText}`);
12
+ }
13
+ const data = await res.json();
14
+ return data.projects;
15
+ };
12
16
 
13
- useEffect(() => {
14
- fetchProjects()
15
- .then(setProjects)
16
- .catch((err) => console.error("Failed to load projects:", err))
17
- .finally(() => setLoading(false));
18
- }, []);
17
+ export function useProjects(): UseProjectsReturn {
18
+ const { data: projects = [], isLoading: loading } = useSWR<string[]>(
19
+ "/api/projects",
20
+ projectsFetcher,
21
+ {
22
+ onErrorRetry: (error, _key, _config, revalidate, { retryCount }) => {
23
+ // Don't retry on 4xx errors
24
+ if (error.status >= 400 && error.status < 500) return;
25
+ // Retry up to 5 times on network errors with exponential backoff
26
+ if (retryCount >= 5) return;
27
+ setTimeout(() => revalidate({ retryCount }), 500 * (retryCount + 1));
28
+ },
29
+ revalidateOnFocus: false,
30
+ },
31
+ );
19
32
 
20
33
  return { projects, loading };
21
34
  }
@@ -1,6 +1,6 @@
1
1
  import { createRoot } from "react-dom/client";
2
2
  import { App } from "./App.tsx";
3
- import "../../styles.css";
3
+ import "../styles/styles.css";
4
4
 
5
5
  const container = document.getElementById("root");
6
6
  if (!container) {
@@ -15,5 +15,11 @@ export interface Plan extends PlanMetadata {
15
15
  content?: string; // Lazy-loaded on demand
16
16
  }
17
17
 
18
- export type SortKey = "title" | "project" | "modified" | "size" | "lines" | "created";
18
+ export type SortKey =
19
+ | "title"
20
+ | "project"
21
+ | "modified"
22
+ | "size"
23
+ | "lines"
24
+ | "created";
19
25
  export type SortDir = "asc" | "desc";
@@ -1,6 +1,8 @@
1
1
  import type { Plan, PlanMetadata } from "../types.ts";
2
2
 
3
- export async function fetchPlans(signal?: AbortSignal): Promise<PlanMetadata[]> {
3
+ export async function fetchPlans(
4
+ signal?: AbortSignal,
5
+ ): Promise<PlanMetadata[]> {
4
6
  const url = new URL("/api/plans", window.location.origin);
5
7
  const res = await fetch(url.toString(), { signal });
6
8
  if (!res.ok) {
@@ -33,6 +35,13 @@ export async function openInEditor(filepath: string): Promise<void> {
33
35
  });
34
36
  }
35
37
 
36
- export async function refreshCache(): Promise<void> {
37
- await fetch("/api/refresh", { method: "POST" });
38
- }
38
+ export interface RefreshResponse {
39
+ success: boolean;
40
+ before: number;
41
+ after: number;
42
+ }
43
+
44
+ export async function refreshCache(): Promise<RefreshResponse> {
45
+ const res = await fetch("/api/refresh", { method: "POST" });
46
+ return res.json();
47
+ }
@@ -10,7 +10,11 @@ export function formatDate(iso: string): string {
10
10
 
11
11
  // Show year if not current year
12
12
  if (d.getFullYear() !== now.getFullYear()) {
13
- return d.toLocaleDateString("en-US", { month: "short", day: "numeric", year: "numeric" });
13
+ return d.toLocaleDateString("en-US", {
14
+ month: "short",
15
+ day: "numeric",
16
+ year: "numeric",
17
+ });
14
18
  }
15
19
 
16
20
  return d.toLocaleDateString("en-US", { month: "short", day: "numeric" });
@@ -26,15 +30,40 @@ export function formatFullDate(iso: string): string {
26
30
  });
27
31
  }
28
32
 
29
- export function formatDateISO(iso: string): string {
30
- const d = new Date(iso);
31
- const year = d.getFullYear();
32
- const month = String(d.getMonth() + 1).padStart(2, "0");
33
- const day = String(d.getDate()).padStart(2, "0");
34
- return `${year}-${month}-${day}`;
35
- }
36
-
37
33
  export function formatSize(bytes: number): string {
38
34
  if (bytes < 1024) return bytes + " B";
39
35
  return (bytes / 1024).toFixed(1) + " KB";
40
36
  }
37
+
38
+ export function formatCompactDateTime(iso: string): string {
39
+ const d = new Date(iso);
40
+ const now = new Date();
41
+
42
+ const time = d.toLocaleTimeString("en-US", {
43
+ hour: "2-digit",
44
+ minute: "2-digit",
45
+ hour12: false,
46
+ });
47
+
48
+ const dateOpts: Intl.DateTimeFormatOptions = {
49
+ month: "short",
50
+ day: "numeric",
51
+ };
52
+ if (d.getFullYear() !== now.getFullYear()) {
53
+ dateOpts.year = "numeric";
54
+ }
55
+
56
+ const date = d.toLocaleDateString("en-US", dateOpts);
57
+ return `${date} · ${time}`;
58
+ }
59
+
60
+ export function isSameDay(iso1: string, iso2: string): boolean {
61
+ const d1 = new Date(iso1);
62
+ const d2 = new Date(iso2);
63
+ return d1.toDateString() === d2.toDateString();
64
+ }
65
+
66
+ export function formatCreatedShort(iso: string): string {
67
+ const d = new Date(iso);
68
+ return d.toLocaleDateString("en-US", { month: "short", day: "numeric" });
69
+ }
@@ -1,15 +1,3 @@
1
1
  export * from "./formatters.ts";
2
2
  export * from "./strings.ts";
3
- export * from "./markdown.ts";
4
3
  export * from "./api.ts";
5
-
6
- export function debounce<Args extends unknown[]>(
7
- fn: (...args: Args) => void,
8
- ms: number
9
- ): (...args: Args) => void {
10
- let timeoutId: ReturnType<typeof setTimeout> | null = null;
11
- return (...args: Args) => {
12
- if (timeoutId) clearTimeout(timeoutId);
13
- timeoutId = setTimeout(() => fn(...args), ms);
14
- };
15
- }
package/src/index.html ADDED
@@ -0,0 +1,13 @@
1
+ <!doctype html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="UTF-8" />
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0" />
6
+ <title>Claude Plans Viewer</title>
7
+ <link rel="stylesheet" href="./styles/styles.css" />
8
+ </head>
9
+ <body>
10
+ <div id="root"></div>
11
+ <script type="module" src="./client/index.tsx"></script>
12
+ </body>
13
+ </html>