Skip to content

Commit 295362b

Browse files
committed
Cache exercise images offline
1 parent d0f4c8b commit 295362b

7 files changed

Lines changed: 142 additions & 6 deletions

File tree

PRIVACY.md

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,8 @@ Pulse does not collect, transmit, sell, or share personal data. It has no accoun
44

55
Your exercise library, weekly programme, settings, and completion history are stored locally on your device. Pulse only sends that data elsewhere when you explicitly export or share a backup.
66

7-
Pulse accepts optional image and guide URLs for exercises. Opening a guide or displaying a remote image connects directly to the service you chose, which is governed by that service's privacy policy. Pulse does not receive data from those requests.
7+
Pulse connects to the internet only when you expand an exercise that has an image URL you supplied. The image is requested directly from that service and cached on your device for later offline viewing. Opening a guide hands its URL to your browser. Those services receive the normal connection information described by their own privacy policies; Pulse has no server and does not receive it.
8+
9+
The image cache keeps at most 40 images and is removed when you clear Pulse's app storage. Android therefore lists network access for Pulse, but the app does not perform background syncing or contact an account, analytics, advertising, or tracking service.
810

911
You can remove Pulse's stored data by clearing the app's storage or uninstalling it. Export a JSON backup first if you want to keep your programme.

README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ Pulse is a local-first weekly workout planner for Android and the web. It is bui
66

77
- Maps custom workouts onto the seven days of the week
88
- Shows the workout that matches the device's current local date
9-
- Saves a reusable exercise library with muscles, equipment, notes, images, and guide links
9+
- Saves a reusable exercise library with muscles, equipment, notes, cached reference images, and guide links
1010
- Stores sets, reps, weight, and rest targets with drag-to-reorder priority
1111
- Tracks completed exercises by date
1212
- Imports and exports the complete programme as JSON

fastlane/metadata/android/en-US/full_description.txt

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ Pulse is a private, offline workout planner built around your training rules.
22

33
Create custom workouts and assign them to any of the seven weekdays. Pulse uses your device's local date to show the correct workout for today, with a clear checklist for the exercises you complete.
44

5-
Build a reusable exercise library with custom muscle groups, equipment, tags, notes, images, and guide links. Add exercises to a workout, set their sets, reps, weight, and rest targets, then drag them into the order you want.
5+
Build a reusable exercise library with custom muscle groups, equipment, tags, notes, cached reference images, and guide links. Images are fetched only after you expand an exercise, then kept on the device for offline viewing. Add exercises to a workout, set their sets, reps, weight, and rest targets, then drag them into the order you want.
66

77
Your programme stays on your device. There are no accounts, ads, analytics, or trackers. Export the complete ledger as JSON whenever you want a portable backup, and import it again on another device.
88

src/lib/exercise-image-cache.ts

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
export const exerciseImageCacheName = "pulse-exercise-images-v1";
2+
export const maxCachedExerciseImages = 40;
3+
4+
type ExerciseImageCache = Pick<Cache, "delete" | "keys" | "match" | "put">;
5+
6+
type ExerciseImageCacheStorage = {
7+
open(name: string): Promise<ExerciseImageCache>;
8+
};
9+
10+
type ExerciseImageFetcher = (request: Request) => Promise<Response>;
11+
12+
export async function loadExerciseImage(
13+
request: Request,
14+
cacheStorage: ExerciseImageCacheStorage = caches,
15+
fetchImage: ExerciseImageFetcher = fetch,
16+
): Promise<Response> {
17+
let cache: ExerciseImageCache | undefined;
18+
19+
try {
20+
cache = await cacheStorage.open(exerciseImageCacheName);
21+
const cached = await cache.match(request);
22+
if (cached) return cached;
23+
} catch {
24+
// Private browsing and storage pressure can make Cache Storage unavailable.
25+
}
26+
27+
const response = await fetchImage(request);
28+
if (!cache || (!response.ok && response.type !== "opaque")) return response;
29+
30+
try {
31+
await cache.put(request, response.clone());
32+
const keys = await cache.keys();
33+
const overflow = Math.max(0, keys.length - maxCachedExerciseImages);
34+
await Promise.all(keys.slice(0, overflow).map((key) => cache.delete(key)));
35+
} catch {
36+
// A failed cache write must not block the image that already loaded.
37+
}
38+
39+
return response;
40+
}

src/routes/+page.svelte

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1145,7 +1145,7 @@
11451145
></textarea></label
11461146
>
11471147
<label class="wide"><span>Reference link · optional</span><input type="url" bind:value={exerciseDraft.guideUrl} placeholder="https://…" /></label>
1148-
<label class="wide"><span>Image link · optional</span><input type="url" bind:value={exerciseDraft.imageUrl} placeholder="https://…" /></label>
1148+
<label class="wide"><span>Image link · cached after first view</span><input type="url" bind:value={exerciseDraft.imageUrl} placeholder="https://…" /></label>
11491149
</div>
11501150
{#if exerciseFormError}<p class="exercise-form-error">
11511151
{exerciseFormError}

src/service-worker.ts

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
/// <reference lib="webworker" />
22

33
import { build, files, prerendered, version } from '$service-worker';
4+
import { exerciseImageCacheName, loadExerciseImage } from '$lib/exercise-image-cache';
45

56
const worker = globalThis as unknown as ServiceWorkerGlobalScope;
67
const cacheName = `pulse-${version}`;
@@ -15,7 +16,11 @@ worker.addEventListener('install', (event) => {
1516
worker.addEventListener('activate', (event) => {
1617
event.waitUntil(
1718
Promise.all([
18-
caches.keys().then((keys) => Promise.all(keys.filter((key) => key.startsWith('pulse-') && key !== cacheName).map((key) => caches.delete(key)))),
19+
caches
20+
.keys()
21+
.then((keys) =>
22+
Promise.all(keys.filter((key) => key.startsWith('pulse-') && key !== cacheName && key !== exerciseImageCacheName).map((key) => caches.delete(key)))
23+
),
1924
worker.clients.claim()
2025
])
2126
);
@@ -26,7 +31,12 @@ worker.addEventListener('fetch', (event) => {
2631
if (request.method !== 'GET') return;
2732

2833
const url = new URL(request.url);
29-
if (url.origin !== worker.location.origin) return;
34+
if (url.origin !== worker.location.origin) {
35+
if (request.destination === 'image' && (url.protocol === 'https:' || url.protocol === 'http:')) {
36+
event.respondWith(loadExerciseImage(request));
37+
}
38+
return;
39+
}
3040

3141
if (appFileSet.has(url.pathname)) {
3242
event.respondWith(caches.match(request).then((cached) => cached ?? fetch(request)));

tests/exercise-image-cache.test.ts

Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,84 @@
1+
import { describe, expect, test } from "bun:test";
2+
import {
3+
exerciseImageCacheName,
4+
loadExerciseImage,
5+
maxCachedExerciseImages,
6+
} from "../src/lib/exercise-image-cache";
7+
8+
class MemoryImageCache {
9+
entries = new Map<string, Response>();
10+
11+
async delete(request: Request) {
12+
return this.entries.delete(request.url);
13+
}
14+
15+
async keys() {
16+
return [...this.entries.keys()].map((url) => new Request(url));
17+
}
18+
19+
async match(request: Request) {
20+
return this.entries.get(request.url)?.clone();
21+
}
22+
23+
async put(request: Request, response: Response) {
24+
this.entries.set(request.url, response);
25+
}
26+
}
27+
28+
describe("exercise image cache", () => {
29+
test("uses the cached image without another request", async () => {
30+
const cache = new MemoryImageCache();
31+
const request = new Request("https://example.com/press.webp");
32+
cache.entries.set(request.url, new Response("cached"));
33+
let fetches = 0;
34+
35+
const response = await loadExerciseImage(
36+
request,
37+
{
38+
open: async (name) => {
39+
expect(name).toBe(exerciseImageCacheName);
40+
return cache;
41+
},
42+
},
43+
async () => {
44+
fetches += 1;
45+
return new Response("network");
46+
},
47+
);
48+
49+
expect(await response.text()).toBe("cached");
50+
expect(fetches).toBe(0);
51+
});
52+
53+
test("stores fetched images and removes the oldest beyond the limit", async () => {
54+
const cache = new MemoryImageCache();
55+
for (let index = 0; index < maxCachedExerciseImages; index += 1) {
56+
cache.entries.set(
57+
`https://example.com/${index}.webp`,
58+
new Response(String(index)),
59+
);
60+
}
61+
62+
const newest = new Request("https://example.com/new.webp");
63+
const response = await loadExerciseImage(
64+
newest,
65+
{ open: async () => cache },
66+
async () => new Response("new"),
67+
);
68+
69+
expect(await response.text()).toBe("new");
70+
expect(cache.entries.size).toBe(maxCachedExerciseImages);
71+
expect(cache.entries.has("https://example.com/0.webp")).toBeFalse();
72+
expect(cache.entries.has(newest.url)).toBeTrue();
73+
});
74+
75+
test("still loads from the network when device caching is unavailable", async () => {
76+
const response = await loadExerciseImage(
77+
new Request("https://example.com/row.webp"),
78+
{ open: async () => Promise.reject(new Error("storage unavailable")) },
79+
async () => new Response("network"),
80+
);
81+
82+
expect(await response.text()).toBe("network");
83+
});
84+
});

0 commit comments

Comments
 (0)