4 Commits

Author SHA1 Message Date
Aculix Technologies bfffb4ac02 chore: 2.1.1 2026-08-16 02:39:57 +05:30
Aculix Technologies b846717db9 fix: touch reachability, contrast and alignment
A pass over desktop, tablet and phone widths, measuring rather than
eyeballing.

The one that matters: a task could not be deleted on a phone. The delete
button is revealed on hover, touch devices report hover: none, so it sat
at zero opacity with nothing to reveal it. The README told people to hover
over a task and click it. It is now always visible where hover does not
exist.

Touch targets were under the 44px minimum: the checkbox at 24, delete at
32, the footer links at 23 tall. They now get 44px hit areas from overlays
that leave the drawn size alone, behind a pointer: coarse query so a mouse
keeps small precise targets and the full row stays draggable.

Two contrast failures in light mode. The date was the brand blue on the
near-white background at 3.54:1, so text now uses a darker --accent-text
and reads 5.14:1. Completed rows carried opacity 0.8 over an already
mid-contrast blue, which came to 3.34:1; the tint, the strikethrough and
the colour say "done" well enough without it, and dropping it gives 4.78:1.
Both themes now pass AA everywhere measured.

The checkbox sat vertically centred, so on a task wrapping to five lines
it floated in the middle of the block. Rows align to the top and task text
gets a 24px line box, matching the checkbox exactly, which also spaces
wrapped lines better.

Reduced motion was only honoured by the empty-state drawing. Svelte's
transitions run in JavaScript and never saw the media query, so tasks
still flew in on a stagger. The component reads the preference directly
and collapses its durations, and CSS covers the rest. The lift on a
dragged card stays, since it tracks the pointer rather than playing at you.

Long unbroken words already wrapped and no breakpoint scrolled sideways.
2026-08-16 02:36:48 +05:30
Aculix Technologies 9053f15ad4 chore: 2.1.0 2026-08-16 02:16:40 +05:30
Aculix Technologies e6dd24a046 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.
2026-08-16 02:16:31 +05:30
14 changed files with 705 additions and 16 deletions
+20 -1
View File
@@ -20,6 +20,8 @@ Built with Svelte for speed and simplicity. No overwhelming features, no endless
- 🎯 **Reorder by drag or keyboard** - Drag with a mouse, long-press and drag on touch, or use `Alt+↑`/`Alt+↓` - 🎯 **Reorder by drag or keyboard** - Drag with a mouse, long-press and drag on touch, or use `Alt+↑`/`Alt+↓`
- 💾 **Persistent storage** - All tasks saved locally in your browser - 💾 **Persistent storage** - All tasks saved locally in your browser
- 📊 **Task statistics** - See how many tasks remain at a glance - 📊 **Task statistics** - See how many tasks remain at a glance
- 📱 **Installable and works offline** - Add it to your home screen or dock and it runs with no connection
- 📦 **Export and import** - Take your tasks with you, or keep a backup
## 🚀 Getting Started ## 🚀 Getting Started
@@ -86,7 +88,7 @@ The date, storage, rollover, task and undo logic lives in `src/lib/` as plain mo
### Managing Tasks ### Managing Tasks
- **Add a task**: Type in the input field and press Enter - **Add a task**: Type in the input field and press Enter
- **Complete a task**: Click the checkbox next to the task - **Complete a task**: Click the checkbox next to the task
- **Delete a task**: Hover over a task and click the delete icon, or press `Delete` with the task focused - **Delete a task**: Click the delete icon on the task (it shows on hover, and is always visible on touch), or press `Delete` with the task focused
- **Undo a delete**: Press `Cmd/Ctrl+Z`. The task returns to where it was - **Undo a delete**: Press `Cmd/Ctrl+Z`. The task returns to where it was
- **Reorder tasks**: Drag with a mouse, long-press then drag on touch, or focus a task and press `Alt+↑`/`Alt+↓` - **Reorder tasks**: Drag with a mouse, long-press then drag on touch, or focus a task and press `Alt+↑`/`Alt+↓`
- **Clear input**: Press Escape while the input is focused - **Clear input**: Press Escape while the input is focused
@@ -98,6 +100,22 @@ The date, storage, rollover, task and undo logic lives in `src/lib/` as plain mo
- **Carry-over**: When a new day begins, whatever you didn't finish moves into Today. Completed tasks are cleared away with the day they belonged to. It works across gaps too. If you don't open Negotium for a week, everything still outstanding is waiting for you. - **Carry-over**: When a new day begins, whatever you didn't finish moves into Today. Completed tasks are cleared away with the day they belonged to. It works across gaps too. If you don't open Negotium for a week, everything still outstanding is waiting for you.
- **While it's open**: The app notices the day change on its own, so a tab left open overnight rolls over without a reload. - **While it's open**: The app notices the day change on its own, so a tab left open overnight rolls over without a reload.
### Installing It
Negotium is a PWA, so it installs like an app and runs without a connection.
- **iPhone and iPad**: open it in Safari, tap Share, then Add to Home Screen
- **Android**: Chrome offers Install from the menu, or prompts you directly
- **Desktop**: Chrome and Edge show an install button in the address bar
Once installed it opens in its own window with no browser chrome, and works on a plane. The app never needed the network for anything beyond loading itself.
### Backing Up and Moving Between Devices
Everything lives in one browser's storage, so `Export` writes it all to a JSON file you can keep or carry somewhere else. `Import` reads that file back.
Import only ever adds. Tasks already present are left alone, so importing the same file twice changes nothing and importing into a list you're already using can't lose anything. The flip side is that import restores rather than reverts: it won't undo work you did after the export.
### Theme Toggle ### Theme Toggle
- Click the sun/moon icon in the header to switch themes - Click the sun/moon icon in the header to switch themes
- Your preference is saved automatically and restored on reload - Your preference is saved automatically and restored on reload
@@ -121,6 +139,7 @@ All data is stored locally in your browser using localStorage:
- **No server required**: Everything runs entirely client-side - **No server required**: Everything runs entirely client-side
- **Privacy first**: Your data never leaves your device - **Privacy first**: Your data never leaves your device
- **Self-pruning**: Past days are removed as their unfinished tasks carry forward, so storage doesn't grow without bound - **Self-pruning**: Past days are removed as their unfinished tasks carry forward, so storage doesn't grow without bound
- **Yours to take**: Export writes everything to a JSON file, so your tasks aren't trapped in one browser
Earlier versions keyed tasks by a different date format (`negotium-tasks-Sat Aug 15 2026`). Those convert automatically the first time you open this version. Nothing to do, and nothing is lost. Earlier versions keyed tasks by a different date format (`negotium-tasks-Sat Aug 15 2026`). Those convert automatically the first time you open this version. Nothing to do, and nothing is lost.
+8 -1
View File
@@ -2,10 +2,17 @@
<html lang="en"> <html lang="en">
<head> <head>
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover">
<title>Negotium - Your Productivity Companion</title> <title>Negotium - Your Productivity Companion</title>
<meta name="description" content="A clean, minimal to-do list application with smooth animations and dark/light mode support"> <meta name="description" content="A clean, minimal to-do list application with smooth animations and dark/light mode support">
<link rel="icon" type="image/svg+xml" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none'><path d='M12.37 8.87988H17.62' stroke='%23607afb' stroke-width='1.5' stroke-linecap='round' stroke-linejoin='round'/><path d='M6.38 8.87988L7.13 9.62988L9.38 7.37988' stroke='%23607afb' stroke-width='1.5' stroke-linecap='round' stroke-linejoin='round'/><path d='M12.37 15.8799H17.62' stroke='%23607afb' stroke-width='1.5' stroke-linecap='round' stroke-linejoin='round'/><path d='M6.38 15.8799L7.13 16.6299L9.38 14.3799' stroke='%23607afb' stroke-width='1.5' 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='%23607afb' stroke-width='1.5' stroke-linecap='round' stroke-linejoin='round'/></svg>"> <link rel="icon" type="image/svg+xml" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none'><path d='M12.37 8.87988H17.62' stroke='%23607afb' stroke-width='1.5' stroke-linecap='round' stroke-linejoin='round'/><path d='M6.38 8.87988L7.13 9.62988L9.38 7.37988' stroke='%23607afb' stroke-width='1.5' stroke-linecap='round' stroke-linejoin='round'/><path d='M12.37 15.8799H17.62' stroke='%23607afb' stroke-width='1.5' stroke-linecap='round' stroke-linejoin='round'/><path d='M6.38 15.8799L7.13 16.6299L9.38 14.3799' stroke='%23607afb' stroke-width='1.5' 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='%23607afb' stroke-width='1.5' stroke-linecap='round' stroke-linejoin='round'/></svg>">
<link rel="manifest" href="/manifest.webmanifest">
<link rel="apple-touch-icon" href="/apple-touch-icon.png">
<meta name="apple-mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-title" content="Negotium">
<!-- Two of these, so the phone's status bar matches whichever theme is on. -->
<meta name="theme-color" content="#F8FAFB" media="(prefers-color-scheme: light)">
<meta name="theme-color" content="#121212" media="(prefers-color-scheme: dark)">
<script> <script>
// Runs before first paint so dark-mode users never see a light flash. // Runs before first paint so dark-mode users never see a light flash.
// App.svelte seeds its own state from the class this sets, keeping one // App.svelte seeds its own state from the class this sets, keeping one
+2 -2
View File
@@ -1,12 +1,12 @@
{ {
"name": "negotium-todo", "name": "negotium-todo",
"version": "2.0.0", "version": "2.1.1",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "negotium-todo", "name": "negotium-todo",
"version": "2.0.0", "version": "2.1.1",
"license": "MIT", "license": "MIT",
"devDependencies": { "devDependencies": {
"@sveltejs/vite-plugin-svelte": "^7.3.0", "@sveltejs/vite-plugin-svelte": "^7.3.0",
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "negotium-todo", "name": "negotium-todo",
"version": "2.0.0", "version": "2.1.1",
"description": "A clean, minimal to-do list application with smooth animations and dark/light mode support", "description": "A clean, minimal to-do list application with smooth animations and dark/light mode support",
"type": "module", "type": "module",
"main": "index.html", "main": "index.html",
Binary file not shown.

After

Width:  |  Height:  |  Size: 3.3 KiB

+10
View File
@@ -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

+10
View File
@@ -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

+30
View File
@@ -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"
}
]
}
+80
View File
@@ -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))
})
+98 -4
View File
@@ -11,6 +11,7 @@
import * as taskOps from './lib/tasks.js'; import * as taskOps from './lib/tasks.js';
import { createUndoStack, applyUndo } from './lib/undo.js'; import { createUndoStack, applyUndo } from './lib/undo.js';
import { shouldHandleUndo } from './lib/shortcuts.js'; import { shouldHandleUndo } from './lib/shortcuts.js';
import { buildExport, serialize, parseImport, mergeImport } from './lib/backup.js';
const storage = createStorage(); const storage = createStorage();
const undoStack = createUndoStack(); const undoStack = createUndoStack();
@@ -83,6 +84,76 @@
darkMode = !darkMode; darkMode = !darkMode;
} }
// Svelte's transitions are driven in JavaScript, so the CSS media query in
// style.css cannot reach them. Read the same preference here and collapse the
// durations. The lift on a dragged card stays: it tracks the pointer rather
// than playing at you, and losing it would make dragging harder to follow.
const motionQuery = window.matchMedia('(prefers-reduced-motion: reduce)');
let reduceMotion = $state(motionQuery.matches);
const flyIn = (index) =>
reduceMotion ? { duration: 0 } : { y: -10, duration: 300, delay: index * 30, easing: cubicOut };
const flyOut = (index) =>
reduceMotion ? { duration: 0 } : { x: 30, opacity: 0, duration: 250, delay: index * 20, easing: cubicOut };
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 /** 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 * task was focused would toggle that task *and* add whatever was in the
* input. */ * input. */
@@ -126,6 +197,7 @@
const DRAG_THRESHOLD_PX = 8; const DRAG_THRESHOLD_PX = 8;
const LONG_PRESS_MS = 400; const LONG_PRESS_MS = 400;
const SETTLE_MS = 240; const SETTLE_MS = 240;
const STATUS_MS = 4000;
let drag = null; let drag = null;
@@ -340,6 +412,10 @@
}, msUntilNextMidnight(new Date())); }, msUntilNextMidnight(new Date()));
} }
function handleMotionPreference(event) {
reduceMotion = event.matches;
}
function handleVisibility() { function handleVisibility() {
if (document.visibilityState === 'visible') runRollover(); if (document.visibilityState === 'visible') runRollover();
} }
@@ -367,11 +443,13 @@
scheduleMidnight(); scheduleMidnight();
document.addEventListener('visibilitychange', handleVisibility); document.addEventListener('visibilitychange', handleVisibility);
window.addEventListener('focus', runRollover); window.addEventListener('focus', runRollover);
motionQuery.addEventListener('change', handleMotionPreference);
return () => { return () => {
clearTimeout(midnightTimer); clearTimeout(midnightTimer);
document.removeEventListener('visibilitychange', handleVisibility); document.removeEventListener('visibilitychange', handleVisibility);
window.removeEventListener('focus', runRollover); window.removeEventListener('focus', runRollover);
motionQuery.removeEventListener('change', handleMotionPreference);
}; };
}); });
</script> </script>
@@ -459,7 +537,7 @@
<div class="task-list"> <div class="task-list">
{#key selectedKey} {#key selectedKey}
{#if tasks.length === 0} {#if tasks.length === 0}
<div class="empty-state" transition:fade={{ duration: 200 }}> <div class="empty-state" transition:fade={{ duration: reduceMotion ? 0 : 200 }}>
<svg class="empty-art" viewBox="0 0 120 120" fill="none" aria-hidden="true" xmlns="http://www.w3.org/2000/svg"> <svg class="empty-art" viewBox="0 0 120 120" fill="none" aria-hidden="true" xmlns="http://www.w3.org/2000/svg">
<g class="empty-art-motes" stroke="currentColor" stroke-width="3" stroke-linecap="round"> <g class="empty-art-motes" stroke="currentColor" stroke-width="3" stroke-linecap="round">
<path d="M40 34 L40 34" /> <path d="M40 34 L40 34" />
@@ -491,9 +569,9 @@
class:dragging={task.id === draggedId} class:dragging={task.id === draggedId}
class:settling={task.id === settlingId} class:settling={task.id === settlingId}
style={rowStyle(task.id)} style={rowStyle(task.id)}
animate:flip={{ duration: task.id === draggedId ? 0 : 240, easing: cubicOut }} animate:flip={{ duration: (reduceMotion || task.id === draggedId) ? 0 : 240, easing: cubicOut }}
in:fly={{ y: -10, duration: 300, delay: index * 30, easing: cubicOut }} in:fly={flyIn(index)}
out:fly={{ x: 30, opacity: 0, duration: 250, delay: index * 20, easing: cubicOut }} out:fly={flyOut(index)}
onpointerdown={(e) => handlePointerDown(e, index)} onpointerdown={(e) => handlePointerDown(e, index)}
onpointermove={handlePointerMove} onpointermove={handlePointerMove}
onpointerup={handlePointerUp} onpointerup={handlePointerUp}
@@ -534,6 +612,22 @@
{/if} {/if}
{/key} {/key}
</div> </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> </div>
</main> </main>
</div> </div>
+123
View File
@@ -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 }
}
+178
View File
@@ -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'))
})
})
+9
View File
@@ -1,6 +1,15 @@
import { mount } from 'svelte'; import { mount } from 'svelte';
import App from './App.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, { export default mount(App, {
target: document.getElementById('app'), target: document.getElementById('app'),
}); });
+136 -7
View File
@@ -4,6 +4,9 @@
--text-primary: #1A1A1A; --text-primary: #1A1A1A;
--text-secondary: #666666; --text-secondary: #666666;
--accent: #607afb; --accent: #607afb;
/* Darker sibling of --accent for text. The brand blue reads 3.54:1 on the
light background, under the 4.5:1 needed for body-sized text. */
--accent-text: #4C5FD5;
--border: #E0E0E0; --border: #E0E0E0;
--hover: #F5F5F5; --hover: #F5F5F5;
--completed-bg: #EEF1FF; --completed-bg: #EEF1FF;
@@ -18,6 +21,7 @@ html.dark {
--text-primary: #E0E0E0; --text-primary: #E0E0E0;
--text-secondary: #999999; --text-secondary: #999999;
--accent: #7B93FF; --accent: #7B93FF;
--accent-text: #8FA5FF;
--border: #333333; --border: #333333;
--hover: #2A2A2A; --hover: #2A2A2A;
--completed-bg: rgba(96, 122, 251, 0.15); --completed-bg: rgba(96, 122, 251, 0.15);
@@ -46,7 +50,9 @@ body {
.header { .header {
background-color: var(--bg-surface); background-color: var(--bg-surface);
border-bottom: 1px solid var(--border); 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; position: sticky;
top: 0; top: 0;
z-index: 100; z-index: 100;
@@ -149,7 +155,7 @@ body {
} }
.main { .main {
padding: 40px 0; padding: 40px 0 calc(40px + env(safe-area-inset-bottom));
} }
.container { .container {
@@ -175,7 +181,7 @@ body {
.date-display { .date-display {
font-size: 16px; font-size: 16px;
font-weight: 500; font-weight: 500;
color: var(--accent); color: var(--accent-text);
} }
.task-input-container { .task-input-container {
@@ -259,7 +265,7 @@ body {
.task-item { .task-item {
display: flex; display: flex;
align-items: center; align-items: flex-start;
gap: 16px; gap: 16px;
padding: 16px 20px; padding: 16px 20px;
background-color: var(--bg-surface); background-color: var(--bg-surface);
@@ -319,7 +325,6 @@ html.dark .task-item.dragging {
.task-item.completed { .task-item.completed {
background-color: var(--completed-bg); background-color: var(--completed-bg);
opacity: 0.8;
} }
.checkbox { .checkbox {
@@ -363,6 +368,9 @@ html.dark .task-item.dragging {
.task-text { .task-text {
flex: 1; flex: 1;
font-size: 16px; font-size: 16px;
/* 24px line box, the same height as the checkbox, so the two align on the
first line without nudging either. */
line-height: 1.5;
color: var(--text-primary); color: var(--text-primary);
transition: all 300ms ease; transition: all 300ms ease;
word-break: break-word; word-break: break-word;
@@ -377,6 +385,7 @@ html.dark .task-item.dragging {
width: 32px; width: 32px;
height: 32px; height: 32px;
border: none; border: none;
position: relative;
background: transparent; background: transparent;
color: var(--text-secondary); color: var(--text-secondary);
cursor: pointer; cursor: pointer;
@@ -459,11 +468,23 @@ html.dark .task-item.dragging {
} }
/* Decorative only, so respect a reduced-motion preference. */ /* Decorative only, so respect a reduced-motion preference. */
/* Svelte's transitions run in JavaScript and never see this query, so
App.svelte checks the same preference and zeroes its durations. This half
covers everything driven by CSS. */
@media (prefers-reduced-motion: reduce) { @media (prefers-reduced-motion: reduce) {
.empty-art-box, .empty-art-box,
.empty-art-motes path { .empty-art-motes path {
animation: none; animation: none;
} }
*,
*::before,
*::after {
animation-duration: 0.01ms !important;
animation-iteration-count: 1 !important;
transition-duration: 0.01ms !important;
scroll-behavior: auto !important;
}
} }
.empty-state p { .empty-state p {
@@ -471,9 +492,117 @@ html.dark .task-item.dragging {
margin: 0; 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;
}
/* Touch devices.
*
* The delete button is revealed on hover, which does not exist here. Without
* this it stays at zero opacity and a task cannot be deleted on a phone at
* all, which is what the README told people to do.
*
* The rest widens hit areas to the 44px minimum. The overlays are positioned
* so nothing moves: the controls keep the size they were drawn at, they are
* just easier to hit. Kept behind the media query so a mouse still gets small,
* precise targets and the full row stays available to drag. */
@media (hover: none) {
.delete-btn {
opacity: 1;
}
}
@media (pointer: coarse) {
.checkbox::after,
.delete-btn::after {
content: '';
position: absolute;
top: 50%;
left: 50%;
width: 44px;
height: 44px;
transform: translate(-50%, -50%);
}
.today-btn,
.theme-toggle {
height: 44px;
}
.clear-completed,
.data-link {
display: inline-flex;
align-items: center;
min-height: 44px;
}
}
.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) { @media (max-width: 768px) {
.header { .header {
padding: 16px 24px; padding: calc(16px + env(safe-area-inset-top)) 24px 16px;
} }
.header-content { .header-content {
@@ -514,7 +643,7 @@ html.dark .task-item.dragging {
@media (max-width: 480px) { @media (max-width: 480px) {
.header { .header {
padding: 12px 16px; padding: calc(12px + env(safe-area-inset-top)) 16px 12px;
} }
.container { .container {