From 30bb6a942d2b6501116f5e3f4020b2a7af3944d5 Mon Sep 17 00:00:00 2001 From: cesnimda Date: Fri, 3 Jul 2026 04:16:44 +0200 Subject: [PATCH] feat: installable PWA with mobile share-target capture - Corrected manifest (Jobbjakt branding, matching green theme, maskable icons, description/categories/scope/id). - share_target (GET) maps a shared url/link into the same /?add= capture flow the bookmarklet uses, so mobile 'Share -> Jobbjakt' pre-fills Add Job. - resolveCaptureUrl helper (tested) extracts the link from add or from a link embedded in shared text; App uses it and strips the params. - Deliberately no offline service worker: the app deploys frequently and an aggressive cache would risk stale builds (documented in README). - 4 unit tests; build compiles. Co-Authored-By: Claude Fable 5 --- README.md | 3 +- job-tracker-ui/public/manifest.json | 61 +++++++++++++++++---------- job-tracker-ui/src/App.tsx | 13 +++--- job-tracker-ui/src/captureUrl.test.ts | 22 ++++++++++ job-tracker-ui/src/captureUrl.ts | 10 +++++ 5 files changed, 80 insertions(+), 29 deletions(-) create mode 100644 job-tracker-ui/src/captureUrl.test.ts create mode 100644 job-tracker-ui/src/captureUrl.ts diff --git a/README.md b/README.md index 23c5865..e7a4e7f 100644 --- a/README.md +++ b/README.md @@ -12,7 +12,8 @@ Job Tracker is a simple, self-hosted app for tracking job applications with a Re - History/event trail per application (created, status changes, follow-up set, delete/restore) - Export jobs to JSON/CSV + daily scheduled JSON export - Optional “job import” preview from supported job sites (plugins) + optional translation to English -- Quick-capture bookmarklet (Settings): opens `/?add=` to pre-fill Add Job from any posting +- Quick-capture bookmarklet (Settings) + installable PWA with a mobile share-target: both open `/?add=` to pre-fill Add Job from any posting + - Note: no offline service-worker cache is bundled by design (the app is deployed frequently; an aggressive cache would risk serving stale builds). The manifest provides installability and share-to-capture without it. - Optional local AI service for short/full descriptions - Optional Google sign-in (Google ID tokens) to protect the API diff --git a/job-tracker-ui/public/manifest.json b/job-tracker-ui/public/manifest.json index aac13d1..c16891f 100644 --- a/job-tracker-ui/public/manifest.json +++ b/job-tracker-ui/public/manifest.json @@ -1,25 +1,40 @@ { - "short_name": "JobTrack", - "name": "JobTrack — Job Application Tracker", - "icons": [ - { - "src": "favicon.ico", - "sizes": "64x64 32x32 24x24 16x16", - "type": "image/x-icon" - }, - { - "src": "logo192.png", - "type": "image/png", - "sizes": "192x192" - }, - { - "src": "logo512.png", - "type": "image/png", - "sizes": "512x512" - } - ], - "start_url": ".", - "display": "standalone", - "theme_color": "#0b1224", - "background_color": "#0b1224" + "short_name": "Jobbjakt", + "name": "Jobbjakt — Job Application Tracker", + "description": "Track and manage your job applications, tailor CVs, and stay on top of follow-ups.", + "id": "/", + "scope": "/", + "start_url": ".", + "display": "standalone", + "orientation": "portrait-primary", + "categories": ["productivity", "business"], + "theme_color": "#15803d", + "background_color": "#0b1224", + "icons": [ + { + "src": "favicon.ico", + "sizes": "64x64 32x32 24x24 16x16", + "type": "image/x-icon" + }, + { + "src": "logo192.png", + "type": "image/png", + "sizes": "192x192", + "purpose": "any maskable" + }, + { + "src": "logo512.png", + "type": "image/png", + "sizes": "512x512", + "purpose": "any maskable" + } + ], + "share_target": { + "action": "/", + "method": "GET", + "params": { + "url": "add", + "text": "addtext" + } + } } diff --git a/job-tracker-ui/src/App.tsx b/job-tracker-ui/src/App.tsx index 3f4ccee..228c397 100644 --- a/job-tracker-ui/src/App.tsx +++ b/job-tracker-ui/src/App.tsx @@ -32,6 +32,7 @@ import ForgotPasswordPage from "./pages/ForgotPasswordPage"; import ResetPasswordPage from "./pages/ResetPasswordPage"; import RouteErrorPage from "./pages/RouteErrorPage"; import { api } from "./api"; +import { resolveCaptureUrl } from "./captureUrl"; import { clearAuthClientState, setAuthUserKey } from "./auth"; import AppShell, { NavItem } from "./layout/AppShell"; import { clearAccentColor, getAccentColor, getThemeModePref, setAccentColor, setThemeModePref, ThemeModePref } from "./themePrefs"; @@ -126,14 +127,16 @@ function Shell({ jobPageSize, setJobPageSize, jobColumns, setJobColumns, themeMo api.get("/auth/config").then((r) => setRequireAuth(Boolean(r.data?.requireAuth))).catch(() => setRequireAuth(false)); }, []); - // Quick-capture bookmarklet target: /?add= opens Add Job pre-filled. + // Quick-capture target: bookmarklet (/?add=) or PWA share (url in `add`, or a link + // embedded in shared `addtext`). Opens Add Job pre-filled and strips the params. useEffect(() => { - const params = new URLSearchParams(location.search); - const add = params.get("add"); - if (!add) return; - setCaptureUrl(add); + const url = resolveCaptureUrl(location.search); + if (!url) return; + setCaptureUrl(url); setAddOpen(true); + const params = new URLSearchParams(location.search); params.delete("add"); + params.delete("addtext"); navigate({ pathname: location.pathname, search: params.toString() }, { replace: true }); }, [location.search, location.pathname, navigate]); useEffect(() => { diff --git a/job-tracker-ui/src/captureUrl.test.ts b/job-tracker-ui/src/captureUrl.test.ts new file mode 100644 index 0000000..e4f198f --- /dev/null +++ b/job-tracker-ui/src/captureUrl.test.ts @@ -0,0 +1,22 @@ +import { resolveCaptureUrl } from './captureUrl'; + +describe('resolveCaptureUrl', () => { + test('reads the bookmarklet add param', () => { + expect(resolveCaptureUrl('?add=https%3A%2F%2Fexample.com%2Fjob')).toBe('https://example.com/job'); + }); + + test('extracts a url embedded in shared text', () => { + expect(resolveCaptureUrl('?addtext=' + encodeURIComponent('Cool role here https://example.com/job/42 apply now'))) + .toBe('https://example.com/job/42'); + }); + + test('prefers add over addtext', () => { + expect(resolveCaptureUrl('?add=https%3A%2F%2Fa.com&addtext=' + encodeURIComponent('https://b.com'))) + .toBe('https://a.com'); + }); + + test('returns null when there is no url', () => { + expect(resolveCaptureUrl('')).toBeNull(); + expect(resolveCaptureUrl('?addtext=' + encodeURIComponent('just some text, no link'))).toBeNull(); + }); +}); diff --git a/job-tracker-ui/src/captureUrl.ts b/job-tracker-ui/src/captureUrl.ts new file mode 100644 index 0000000..b171afc --- /dev/null +++ b/job-tracker-ui/src/captureUrl.ts @@ -0,0 +1,10 @@ +// Resolves the quick-capture URL from query params produced by the bookmarklet (`add`) +// or the PWA share-target (a link in `add`, or embedded in shared `addtext`). +export function resolveCaptureUrl(search: string): string | null { + const params = new URLSearchParams(search); + const add = params.get("add"); + if (add) return add; + const addText = params.get("addtext"); + if (addText) return addText.match(/https?:\/\/\S+/)?.[0] ?? null; + return null; +}