mirror of
https://github.com/aculix/negotium.git
synced 2026-09-11 07:28:17 +00:00
feat: installable offline app, plus export and import
Two things that were deliberately left out of 2.0.0. Export writes every stored day to a JSON file. Import reads one back, and only ever adds: a task whose id is already there is left alone, so importing the same file twice does nothing and importing into a list you're using can't lose work. That is also why it needs no confirmation dialog. The trade is that import restores rather than reverts. Both sit in a quiet line under the task list rather than the header, since they get used about twice a year and the header is what you look at all day. For the PWA half, the service worker is about fifty lines with no dependency, because the strategy falls out of how Vite builds. Documents go network first and fall back to cache, so a deploy is picked up as soon as you're online and nobody ends up stuck on an old build. Fingerprinted assets go cache first and are kept, since their names change when their contents do. No build-time asset manifest needed. Icons are SVG in the manifest, which stays sharp at any size and costs about a kilobyte, plus one 180px PNG because iOS wants a raster apple-touch-icon. Two theme-color metas so the phone status bar follows the theme, and safe-area padding on the header, without which the header sits under the clock once installed on an iPhone. Verified against the production build: worker registers and claims the page, shell and assets land in cache, and with the server stopped the app still loads, adds a task and persists it.
This commit is contained in:
Binary file not shown.
|
After Width: | Height: | Size: 3.3 KiB |
@@ -0,0 +1,10 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512" width="512" height="512">
|
||||
<rect width="512" height="512" fill="#607afb"/>
|
||||
<g transform="translate(115.2 115.2) scale(11.73)">
|
||||
<path d="M12.37 8.87988H17.62" stroke="#FFFFFF" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M6.38 8.87988L7.13 9.62988L9.38 7.37988" stroke="#FFFFFF" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M12.37 15.8799H17.62" stroke="#FFFFFF" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M6.38 15.8799L7.13 16.6299L9.38 14.3799" stroke="#FFFFFF" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M9 22H15C20 22 22 20 22 15V9C22 4 20 2 15 2H9C4 2 2 4 2 9V15C2 20 4 22 9 22Z" stroke="#FFFFFF" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 890 B |
@@ -0,0 +1,10 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512" width="512" height="512">
|
||||
<rect width="512" height="512" rx="96" fill="#607afb"/>
|
||||
<g transform="translate(87.04 87.04) scale(14.58)">
|
||||
<path d="M12.37 8.87988H17.62" stroke="#FFFFFF" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M6.38 8.87988L7.13 9.62988L9.38 7.37988" stroke="#FFFFFF" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M12.37 15.8799H17.62" stroke="#FFFFFF" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M6.38 15.8799L7.13 16.6299L9.38 14.3799" stroke="#FFFFFF" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M9 22H15C20 22 22 20 22 15V9C22 4 20 2 15 2H9C4 2 2 4 2 9V15C2 20 4 22 9 22Z" stroke="#FFFFFF" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 898 B |
@@ -0,0 +1,30 @@
|
||||
{
|
||||
"name": "Negotium",
|
||||
"short_name": "Negotium",
|
||||
"description": "A minimal to-do list for today and tomorrow.",
|
||||
"start_url": "/",
|
||||
"scope": "/",
|
||||
"display": "standalone",
|
||||
"background_color": "#F8FAFB",
|
||||
"theme_color": "#607afb",
|
||||
"icons": [
|
||||
{
|
||||
"src": "/icon.svg",
|
||||
"sizes": "any",
|
||||
"type": "image/svg+xml",
|
||||
"purpose": "any"
|
||||
},
|
||||
{
|
||||
"src": "/icon-maskable.svg",
|
||||
"sizes": "any",
|
||||
"type": "image/svg+xml",
|
||||
"purpose": "maskable"
|
||||
},
|
||||
{
|
||||
"src": "/apple-touch-icon.png",
|
||||
"sizes": "180x180",
|
||||
"type": "image/png",
|
||||
"purpose": "any"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
// Negotium's service worker. Small on purpose: this app is one HTML file, one
|
||||
// JS bundle, one stylesheet and a couple of icons.
|
||||
//
|
||||
// The strategy leans on a property of the build: Vite fingerprints assets by
|
||||
// content, so index-CE76Mg_z.js can never change meaning. That splits cleanly
|
||||
// in two:
|
||||
//
|
||||
// Documents -> network first, cache as fallback. A new deploy is picked up
|
||||
// the moment you are online, so nobody gets welded to a stale
|
||||
// build. Offline, the last good copy is served.
|
||||
// Everything -> cache first. Fingerprinted files are immutable, and a new
|
||||
// else build simply asks for new filenames.
|
||||
//
|
||||
// Bump CACHE when the caching logic itself changes; old caches are dropped on
|
||||
// activate.
|
||||
|
||||
const CACHE = 'negotium-v1'
|
||||
|
||||
self.addEventListener('install', (event) => {
|
||||
// The shell is cached on first fetch rather than precached, which keeps this
|
||||
// file free of a build-generated asset manifest.
|
||||
event.waitUntil(self.skipWaiting())
|
||||
})
|
||||
|
||||
self.addEventListener('activate', (event) => {
|
||||
event.waitUntil(
|
||||
(async () => {
|
||||
const names = await caches.keys()
|
||||
await Promise.all(names.filter((name) => name !== CACHE).map((name) => caches.delete(name)))
|
||||
await self.clients.claim()
|
||||
})(),
|
||||
)
|
||||
})
|
||||
|
||||
async function networkFirst(request) {
|
||||
const cache = await caches.open(CACHE)
|
||||
|
||||
try {
|
||||
const response = await fetch(request)
|
||||
if (response && response.ok) cache.put(request, response.clone())
|
||||
return response
|
||||
} catch {
|
||||
const cached = await cache.match(request)
|
||||
if (cached) return cached
|
||||
|
||||
// A deep link visited offline that was never cached: fall back to the app
|
||||
// shell, which is all this app needs to boot.
|
||||
const shell = await cache.match('/index.html')
|
||||
if (shell) return shell
|
||||
|
||||
throw new Error('offline and nothing cached')
|
||||
}
|
||||
}
|
||||
|
||||
async function cacheFirst(request) {
|
||||
const cache = await caches.open(CACHE)
|
||||
|
||||
const cached = await cache.match(request)
|
||||
if (cached) return cached
|
||||
|
||||
const response = await fetch(request)
|
||||
if (response && response.ok) cache.put(request, response.clone())
|
||||
return response
|
||||
}
|
||||
|
||||
self.addEventListener('fetch', (event) => {
|
||||
const { request } = event
|
||||
|
||||
if (request.method !== 'GET') return
|
||||
|
||||
const url = new URL(request.url)
|
||||
if (url.origin !== self.location.origin) return
|
||||
|
||||
if (request.mode === 'navigate' || request.destination === 'document') {
|
||||
event.respondWith(networkFirst(request))
|
||||
return
|
||||
}
|
||||
|
||||
event.respondWith(cacheFirst(request))
|
||||
})
|
||||
Reference in New Issue
Block a user