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:
@@ -11,6 +11,7 @@
|
||||
import * as taskOps from './lib/tasks.js';
|
||||
import { createUndoStack, applyUndo } from './lib/undo.js';
|
||||
import { shouldHandleUndo } from './lib/shortcuts.js';
|
||||
import { buildExport, serialize, parseImport, mergeImport } from './lib/backup.js';
|
||||
|
||||
const storage = createStorage();
|
||||
const undoStack = createUndoStack();
|
||||
@@ -83,6 +84,63 @@
|
||||
darkMode = !darkMode;
|
||||
}
|
||||
|
||||
let fileInput;
|
||||
let status = $state('');
|
||||
let statusIsError = $state(false);
|
||||
let statusTimer = null;
|
||||
|
||||
function showStatus(message, isError = false) {
|
||||
status = message;
|
||||
statusIsError = isError;
|
||||
clearTimeout(statusTimer);
|
||||
statusTimer = setTimeout(() => { status = ''; }, STATUS_MS);
|
||||
}
|
||||
|
||||
function exportTasks() {
|
||||
const text = serialize(buildExport(storage));
|
||||
const url = URL.createObjectURL(new Blob([text], { type: 'application/json' }));
|
||||
|
||||
const link = document.createElement('a');
|
||||
link.href = url;
|
||||
link.download = `negotium-${todayKey}.json`;
|
||||
link.click();
|
||||
URL.revokeObjectURL(url);
|
||||
|
||||
showStatus('Exported.');
|
||||
}
|
||||
|
||||
async function importTasks(event) {
|
||||
const file = event.target.files?.[0];
|
||||
// Reset first, so picking the same file twice still fires a change event.
|
||||
event.target.value = '';
|
||||
if (!file) return;
|
||||
|
||||
let parsed;
|
||||
try {
|
||||
parsed = parseImport(await file.text());
|
||||
} catch {
|
||||
showStatus("That file couldn't be read.", true);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!parsed.ok) {
|
||||
showStatus(parsed.error, true);
|
||||
return;
|
||||
}
|
||||
|
||||
const { imported, duplicates, days } = mergeImport(storage, parsed);
|
||||
tasks = storage.loadTasks(selectedKey);
|
||||
|
||||
if (imported === 0) {
|
||||
showStatus(duplicates > 0 ? 'Already up to date.' : 'Nothing to import.');
|
||||
return;
|
||||
}
|
||||
|
||||
const taskWord = imported === 1 ? 'task' : 'tasks';
|
||||
const dayWord = days === 1 ? 'day' : 'days';
|
||||
showStatus(`Imported ${imported} ${taskWord} across ${days} ${dayWord}.`);
|
||||
}
|
||||
|
||||
/** Scoped to the input. Previously this also sat on window, so Enter while a
|
||||
* task was focused would toggle that task *and* add whatever was in the
|
||||
* input. */
|
||||
@@ -126,6 +184,7 @@
|
||||
const DRAG_THRESHOLD_PX = 8;
|
||||
const LONG_PRESS_MS = 400;
|
||||
const SETTLE_MS = 240;
|
||||
const STATUS_MS = 4000;
|
||||
|
||||
let drag = null;
|
||||
|
||||
@@ -534,6 +593,22 @@
|
||||
{/if}
|
||||
{/key}
|
||||
</div>
|
||||
|
||||
<footer class="data-footer">
|
||||
<button class="data-link" onclick={exportTasks}>Export</button>
|
||||
<span class="data-sep" aria-hidden="true">·</span>
|
||||
<button class="data-link" onclick={() => fileInput.click()}>Import</button>
|
||||
<input
|
||||
bind:this={fileInput}
|
||||
type="file"
|
||||
accept="application/json,.json"
|
||||
class="visually-hidden"
|
||||
onchange={importTasks}
|
||||
/>
|
||||
<span class="data-status" class:error={statusIsError} role="status" aria-live="polite">
|
||||
{status}
|
||||
</span>
|
||||
</footer>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,123 @@
|
||||
import { isKey } from './dates.js'
|
||||
|
||||
const APP = 'negotium'
|
||||
const FORMAT_VERSION = 1
|
||||
|
||||
/** Everything currently stored, as a plain object ready to serialize. */
|
||||
export function buildExport(storage, now = new Date()) {
|
||||
const days = {}
|
||||
|
||||
for (const dateKey of storage.listTaskKeys().sort()) {
|
||||
const tasks = storage.loadTasks(dateKey)
|
||||
if (tasks.length > 0) days[dateKey] = tasks
|
||||
}
|
||||
|
||||
return {
|
||||
app: APP,
|
||||
version: FORMAT_VERSION,
|
||||
exportedAt: now.toISOString(),
|
||||
days,
|
||||
}
|
||||
}
|
||||
|
||||
export function serialize(data) {
|
||||
return JSON.stringify(data, null, 2)
|
||||
}
|
||||
|
||||
/** Keeps only the fields we know about, so an edited file can't smuggle
|
||||
* anything unexpected into storage. Returns null if the task is unusable. */
|
||||
function cleanTask(raw) {
|
||||
if (!raw || typeof raw !== 'object') return null
|
||||
|
||||
const hasId = typeof raw.id === 'string' || typeof raw.id === 'number'
|
||||
const text = typeof raw.text === 'string' ? raw.text.trim() : ''
|
||||
if (!hasId || !text) return null
|
||||
|
||||
return {
|
||||
id: String(raw.id),
|
||||
text,
|
||||
completed: Boolean(raw.completed),
|
||||
createdAt: typeof raw.createdAt === 'number' ? raw.createdAt : Date.now(),
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates a file's contents without touching storage.
|
||||
*
|
||||
* Anything malformed is skipped rather than failing the whole import: one bad
|
||||
* row in a hand-edited file shouldn't cost someone the other two hundred.
|
||||
*/
|
||||
export function parseImport(text) {
|
||||
let raw
|
||||
try {
|
||||
raw = JSON.parse(text)
|
||||
} catch {
|
||||
return { ok: false, error: "That file isn't a valid JSON file." }
|
||||
}
|
||||
|
||||
if (!raw || typeof raw !== 'object' || Array.isArray(raw)) {
|
||||
return { ok: false, error: "That file doesn't look like a Negotium export." }
|
||||
}
|
||||
|
||||
if (raw.app !== APP || !raw.days || typeof raw.days !== 'object' || Array.isArray(raw.days)) {
|
||||
return { ok: false, error: "That file doesn't look like a Negotium export." }
|
||||
}
|
||||
|
||||
const days = {}
|
||||
let taskCount = 0
|
||||
let skipped = 0
|
||||
|
||||
for (const [dateKey, value] of Object.entries(raw.days)) {
|
||||
if (!isKey(dateKey) || !Array.isArray(value)) continue
|
||||
|
||||
const tasks = []
|
||||
for (const entry of value) {
|
||||
const task = cleanTask(entry)
|
||||
if (task) tasks.push(task)
|
||||
else skipped += 1
|
||||
}
|
||||
|
||||
if (tasks.length > 0) {
|
||||
days[dateKey] = tasks
|
||||
taskCount += tasks.length
|
||||
}
|
||||
}
|
||||
|
||||
return { ok: true, days, taskCount, skipped }
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds parsed tasks to storage. Additive by design: a task whose id is already
|
||||
* present is left alone, so importing the same file twice changes nothing and
|
||||
* importing into a live list can't lose work. The trade is that import cannot
|
||||
* be used to roll back to an earlier state.
|
||||
*/
|
||||
export function mergeImport(storage, parsed) {
|
||||
if (!parsed?.ok) return { imported: 0, duplicates: 0, days: 0 }
|
||||
|
||||
let imported = 0
|
||||
let duplicates = 0
|
||||
let days = 0
|
||||
|
||||
for (const [dateKey, incoming] of Object.entries(parsed.days)) {
|
||||
const existing = storage.loadTasks(dateKey)
|
||||
const seen = new Set(existing.map(task => String(task.id)))
|
||||
|
||||
const additions = []
|
||||
for (const task of incoming) {
|
||||
if (seen.has(task.id)) duplicates += 1
|
||||
else {
|
||||
additions.push(task)
|
||||
seen.add(task.id)
|
||||
}
|
||||
}
|
||||
|
||||
if (additions.length > 0) {
|
||||
storage.saveTasks(dateKey, [...existing, ...additions])
|
||||
imported += additions.length
|
||||
days += 1
|
||||
}
|
||||
}
|
||||
|
||||
return { imported, duplicates, days }
|
||||
}
|
||||
@@ -0,0 +1,178 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { createStorage, createMemoryStore } from './storage.js'
|
||||
import { buildExport, serialize, parseImport, mergeImport } from './backup.js'
|
||||
|
||||
const task = (id, text = id, completed = false) => ({ id, text, completed, createdAt: 1000 })
|
||||
|
||||
const setup = (seed = {}) => {
|
||||
const backend = createMemoryStore()
|
||||
for (const [day, tasks] of Object.entries(seed)) {
|
||||
backend.setItem(`negotium-tasks-${day}`, JSON.stringify(tasks))
|
||||
}
|
||||
return createStorage(backend)
|
||||
}
|
||||
|
||||
const file = (days, extra = {}) =>
|
||||
JSON.stringify({ app: 'negotium', version: 1, exportedAt: '2026-08-16T00:00:00.000Z', days, ...extra })
|
||||
|
||||
describe('buildExport', () => {
|
||||
it('stamps the app, version and time', () => {
|
||||
const result = buildExport(setup(), new Date(Date.UTC(2026, 7, 16, 12)))
|
||||
expect(result.app).toBe('negotium')
|
||||
expect(result.version).toBe(1)
|
||||
expect(result.exportedAt).toBe('2026-08-16T12:00:00.000Z')
|
||||
})
|
||||
|
||||
it('includes every stored day', () => {
|
||||
const storage = setup({ '2026-08-16': [task('a')], '2026-08-17': [task('b')] })
|
||||
expect(Object.keys(buildExport(storage).days).sort()).toEqual(['2026-08-16', '2026-08-17'])
|
||||
})
|
||||
|
||||
it('omits days holding no tasks', () => {
|
||||
const storage = setup({ '2026-08-16': [task('a')], '2026-08-17': [] })
|
||||
expect(Object.keys(buildExport(storage).days)).toEqual(['2026-08-16'])
|
||||
})
|
||||
|
||||
it('exports an empty days object when nothing is stored', () => {
|
||||
expect(buildExport(setup()).days).toEqual({})
|
||||
})
|
||||
|
||||
it('serializes to text a human can read', () => {
|
||||
const text = serialize(buildExport(setup({ '2026-08-16': [task('a')] })))
|
||||
expect(text).toContain('\n')
|
||||
expect(JSON.parse(text).days['2026-08-16']).toHaveLength(1)
|
||||
})
|
||||
})
|
||||
|
||||
describe('parseImport', () => {
|
||||
it('rejects text that is not JSON', () => {
|
||||
const result = parseImport('{not json')
|
||||
expect(result.ok).toBe(false)
|
||||
expect(result.error).toMatch(/valid JSON/i)
|
||||
})
|
||||
|
||||
it('rejects a file from somewhere else', () => {
|
||||
const result = parseImport(JSON.stringify({ app: 'other', days: {} }))
|
||||
expect(result.ok).toBe(false)
|
||||
expect(result.error).toMatch(/negotium/i)
|
||||
})
|
||||
|
||||
it('rejects a file with no days object', () => {
|
||||
expect(parseImport(JSON.stringify({ app: 'negotium' })).ok).toBe(false)
|
||||
})
|
||||
|
||||
it('rejects a top-level array', () => {
|
||||
expect(parseImport('[]').ok).toBe(false)
|
||||
})
|
||||
|
||||
it('accepts a valid file and counts what it found', () => {
|
||||
const result = parseImport(file({ '2026-08-16': [task('a'), task('b')], '2026-08-17': [task('c')] }))
|
||||
expect(result.ok).toBe(true)
|
||||
expect(result.taskCount).toBe(3)
|
||||
expect(Object.keys(result.days)).toHaveLength(2)
|
||||
})
|
||||
|
||||
it('ignores unknown top-level fields', () => {
|
||||
const result = parseImport(file({ '2026-08-16': [task('a')] }, { somethingElse: 42 }))
|
||||
expect(result.ok).toBe(true)
|
||||
expect(result.taskCount).toBe(1)
|
||||
})
|
||||
|
||||
it('skips days whose key is not a date', () => {
|
||||
const result = parseImport(file({ 'not-a-date': [task('a')], '2026-08-16': [task('b')] }))
|
||||
expect(Object.keys(result.days)).toEqual(['2026-08-16'])
|
||||
})
|
||||
|
||||
it('skips a day that is not an array', () => {
|
||||
const result = parseImport(file({ '2026-08-16': { nope: true } }))
|
||||
expect(result.days).toEqual({})
|
||||
})
|
||||
|
||||
it('skips tasks with no usable text and counts them', () => {
|
||||
const result = parseImport(file({ '2026-08-16': [task('a'), { id: 'b' }, { id: 'c', text: ' ' }] }))
|
||||
expect(result.taskCount).toBe(1)
|
||||
expect(result.skipped).toBe(2)
|
||||
})
|
||||
|
||||
it('skips tasks with no id', () => {
|
||||
const result = parseImport(file({ '2026-08-16': [{ text: 'orphan' }] }))
|
||||
expect(result.taskCount).toBe(0)
|
||||
expect(result.skipped).toBe(1)
|
||||
})
|
||||
|
||||
it('coerces completed to a boolean', () => {
|
||||
const result = parseImport(file({ '2026-08-16': [{ id: 'a', text: 'x', completed: 'yes' }] }))
|
||||
expect(result.days['2026-08-16'][0].completed).toBe(true)
|
||||
})
|
||||
|
||||
it('trims task text', () => {
|
||||
const result = parseImport(file({ '2026-08-16': [{ id: 'a', text: ' spaced ' }] }))
|
||||
expect(result.days['2026-08-16'][0].text).toBe('spaced')
|
||||
})
|
||||
|
||||
it('drops fields it does not recognise', () => {
|
||||
const result = parseImport(file({ '2026-08-16': [{ id: 'a', text: 'x', evil: '<script>' }] }))
|
||||
expect(result.days['2026-08-16'][0]).not.toHaveProperty('evil')
|
||||
})
|
||||
})
|
||||
|
||||
describe('mergeImport', () => {
|
||||
it('imports into an empty store', () => {
|
||||
const storage = setup()
|
||||
const parsed = parseImport(file({ '2026-08-16': [task('a'), task('b')] }))
|
||||
const result = mergeImport(storage, parsed)
|
||||
|
||||
expect(result.imported).toBe(2)
|
||||
expect(storage.loadTasks('2026-08-16').map(t => t.id)).toEqual(['a', 'b'])
|
||||
})
|
||||
|
||||
it('appends to a day that already has tasks', () => {
|
||||
const storage = setup({ '2026-08-16': [task('existing')] })
|
||||
mergeImport(storage, parseImport(file({ '2026-08-16': [task('new')] })))
|
||||
expect(storage.loadTasks('2026-08-16').map(t => t.id)).toEqual(['existing', 'new'])
|
||||
})
|
||||
|
||||
it('never overwrites a task already present', () => {
|
||||
const storage = setup({ '2026-08-16': [task('a', 'mine')] })
|
||||
mergeImport(storage, parseImport(file({ '2026-08-16': [task('a', 'theirs')] })))
|
||||
|
||||
const stored = storage.loadTasks('2026-08-16')
|
||||
expect(stored).toHaveLength(1)
|
||||
expect(stored[0].text).toBe('mine')
|
||||
})
|
||||
|
||||
it('reports duplicates as skipped', () => {
|
||||
const storage = setup({ '2026-08-16': [task('a')] })
|
||||
const result = mergeImport(storage, parseImport(file({ '2026-08-16': [task('a'), task('b')] })))
|
||||
expect(result.imported).toBe(1)
|
||||
expect(result.duplicates).toBe(1)
|
||||
})
|
||||
|
||||
it('is a no-op the second time the same file is imported', () => {
|
||||
const storage = setup()
|
||||
const parsed = parseImport(file({ '2026-08-16': [task('a'), task('b')] }))
|
||||
|
||||
mergeImport(storage, parsed)
|
||||
const second = mergeImport(storage, parsed)
|
||||
|
||||
expect(second.imported).toBe(0)
|
||||
expect(storage.loadTasks('2026-08-16')).toHaveLength(2)
|
||||
})
|
||||
|
||||
it('counts the days it touched', () => {
|
||||
const storage = setup()
|
||||
const parsed = parseImport(file({ '2026-08-16': [task('a')], '2026-08-17': [task('b')] }))
|
||||
expect(mergeImport(storage, parsed).days).toBe(2)
|
||||
})
|
||||
|
||||
it('round-trips an export back into an empty store', () => {
|
||||
const source = setup({ '2026-08-16': [task('a'), task('b')], '2026-08-17': [task('c')] })
|
||||
const text = serialize(buildExport(source))
|
||||
|
||||
const target = setup()
|
||||
mergeImport(target, parseImport(text))
|
||||
|
||||
expect(target.loadTasks('2026-08-16')).toEqual(source.loadTasks('2026-08-16'))
|
||||
expect(target.loadTasks('2026-08-17')).toEqual(source.loadTasks('2026-08-17'))
|
||||
})
|
||||
})
|
||||
@@ -1,6 +1,15 @@
|
||||
import { mount } from 'svelte';
|
||||
import App from './App.svelte';
|
||||
|
||||
// Production only. A service worker in dev caches your own edits back at you.
|
||||
if (import.meta.env.PROD && 'serviceWorker' in navigator) {
|
||||
window.addEventListener('load', () => {
|
||||
navigator.serviceWorker.register('/sw.js').catch(() => {
|
||||
// Offline support is a bonus; the app works without it.
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
export default mount(App, {
|
||||
target: document.getElementById('app'),
|
||||
});
|
||||
|
||||
+73
-4
@@ -46,7 +46,9 @@ body {
|
||||
.header {
|
||||
background-color: var(--bg-surface);
|
||||
border-bottom: 1px solid var(--border);
|
||||
padding: 24px 48px;
|
||||
/* The extra top padding is the iOS status bar when the app is installed to
|
||||
the home screen. Without it the header sits underneath the clock. */
|
||||
padding: calc(24px + env(safe-area-inset-top)) 48px 24px;
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 100;
|
||||
@@ -149,7 +151,7 @@ body {
|
||||
}
|
||||
|
||||
.main {
|
||||
padding: 40px 0;
|
||||
padding: 40px 0 calc(40px + env(safe-area-inset-bottom));
|
||||
}
|
||||
|
||||
.container {
|
||||
@@ -471,9 +473,76 @@ html.dark .task-item.dragging {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
/* Deliberately quiet. Export and import get reached for once in a blue moon,
|
||||
so they sit below the list in secondary text rather than taking a slot in
|
||||
the header next to things you use constantly. */
|
||||
.data-footer {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-top: 32px;
|
||||
padding-top: 16px;
|
||||
border-top: 1px solid var(--border);
|
||||
font-size: 13px;
|
||||
color: var(--text-secondary);
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.data-link {
|
||||
background: transparent;
|
||||
border: none;
|
||||
padding: 4px 6px;
|
||||
margin: 0;
|
||||
border-radius: 4px;
|
||||
font-family: var(--font-family);
|
||||
font-size: 13px;
|
||||
color: var(--text-secondary);
|
||||
cursor: pointer;
|
||||
transition: color 200ms ease, background-color 200ms ease;
|
||||
}
|
||||
|
||||
.data-link:hover {
|
||||
color: var(--accent);
|
||||
background-color: var(--hover);
|
||||
}
|
||||
|
||||
.data-sep {
|
||||
color: var(--border);
|
||||
}
|
||||
|
||||
.data-status {
|
||||
margin-left: 4px;
|
||||
opacity: 0;
|
||||
transition: opacity 200ms ease;
|
||||
}
|
||||
|
||||
.data-status:not(:empty) {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.data-status.error {
|
||||
color: #d14343;
|
||||
}
|
||||
|
||||
html.dark .data-status.error {
|
||||
color: #ff8a8a;
|
||||
}
|
||||
|
||||
.visually-hidden {
|
||||
position: absolute;
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
padding: 0;
|
||||
margin: -1px;
|
||||
overflow: hidden;
|
||||
clip: rect(0, 0, 0, 0);
|
||||
white-space: nowrap;
|
||||
border: 0;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.header {
|
||||
padding: 16px 24px;
|
||||
padding: calc(16px + env(safe-area-inset-top)) 24px 16px;
|
||||
}
|
||||
|
||||
.header-content {
|
||||
@@ -514,7 +583,7 @@ html.dark .task-item.dragging {
|
||||
|
||||
@media (max-width: 480px) {
|
||||
.header {
|
||||
padding: 12px 16px;
|
||||
padding: calc(12px + env(safe-area-inset-top)) 16px 12px;
|
||||
}
|
||||
|
||||
.container {
|
||||
|
||||
Reference in New Issue
Block a user