40 Commits

Author SHA1 Message Date
Aculix Technologies cd3c44fcdc chore: 2.2.2 2026-08-16 18:58:42 +05:30
Aculix Technologies c17dad6f1f fix: give editing its own button and hand the text back to dragging
Making the task text clickable turned it into a button, and the drag
handler skips anything inside a button. That quietly cost the row its
best drag handle: press and move across the text and nothing picked the
card up, so you got a text selection instead. Verified before changing
anything, dragging from the text did nothing while dragging from the row's
padding worked.

Editing moves to a pencil next to the arrow and the bin, and the text goes
back to being text. Rows also stop being selectable, since the row is a
drag handle and highlighting words across one is never what you meant. The
edit field keeps selection so text can still be copied out of it.

A third button costs width on a phone, where all three stay visible
because there is no hover. Grouping the actions so their gaps stop
multiplying across the row, and drawing them at 26px, keeps most of it
back: 199px of text becomes 187, 24 characters a line becomes 23. The
44px tap targets come from the overlay underneath, so a smaller drawn
button does not shrink them.

Also drops the click-after-drag guard, which only existed because the text
was clickable.
2026-08-16 18:57:59 +05:30
Aculix Technologies e54338f815 chore: 2.2.1 2026-08-16 18:51:21 +05:30
Aculix Technologies 7f1a02ee41 fix: moving a task between days is no longer silent
Deferring was the one action that changed your list and said nothing about
it. The task disappeared, no message, and Cmd/Ctrl+Z would not bring it
back because the undo stack only recorded deletes and clear-completed.

That was worse than it sounds. The app had just taught people, via the bar
that appears when something is deleted, that things which vanish come with
a way back. Deferring quietly broke that promise, and anyone who misread
the arrow would reasonably believe they had destroyed the task.

It now uses the same bar, saying which way the task went, and undo sends
it back to the row it left rather than the bottom of the list. That needed
an insert position on moveTaskToDay, which is tested along with the round
trip, clamping and the default append.

Undo for a move is handled separately from applyUndo, which edits a single
day's array and cannot express something that spans two.
2026-08-16 15:01:21 +05:30
Aculix Technologies 54958d0f42 chore: 2.2.0 2026-08-16 14:54:39 +05:30
Aculix Technologies 6071dccec4 fix: recentre task rows, and give the phone layout its space back
Rows stopped being vertically centred. Aligning them to flex-start so the
controls stay on the first line of a wrapped task also let the 32px row
buttons set the row height, which left the 24px checkbox and text sitting
4px above centre on every single-line row. Everything on the first line
now occupies the same 32px band, so a single-line row centres and a
wrapped one still puts its controls on the first line. A padding shorthand
later in the same rule was quietly resetting the longhand that does it.

The phone layout was spending 372px of an 812px screen, 46% of it, before
the first task appeared. The header stacked into two rows because the
wordmark and the controls could not share one: they need 422px and there
are 343. The wordmark goes below 480px, since the logo carries the
identity and an installed app already has its name under the icon, and it
stays in the accessibility tree so the page keeps its h1. The heading and
date share a line again rather than stacking unconditionally. That is 372
down to 261, 46% down to 32%.

That left the rows themselves too tight, because both actions stay visible
where there is no hover and were taking 44% of the row width, cutting the
text to 21 characters a line. Tighter gaps and padding, and a smaller
drawn button whose 44px target comes from the overlay underneath rather
than its own box, bring that back to 24.
2026-08-16 12:12:03 +05:30
Aculix Technologies f9ae76a87d fix: focus rings and tap targets for the new controls
A pass over what the last four changes added.

The focus-visible rule named a handful of controls by hand and had gone
stale. It still pointed at .today-btn, which no longer exists, and none of
the five newer controls were listed, so tabbing to the defer arrow, either
day option, the task text, the Undo button or the dismiss cross showed
nothing at all. They are all covered now, and the row buttons reveal
themselves when focused, since a ring on a transparent button is not much
use.

Tap targets on touch: the day options came out 36px tall because the
switch's own padding and border ate into its height, and the task text was
24. The switch is 46 with the padding removed so its buttons get the full
44, and the theme toggle matches so the header stays level. The text grows
by padding with the same amount taken back as negative margin, so it is
44px to a finger without moving off the first line.

Also drops the fade on the carry-over notice. It was hiding whether the
thing was actually unmounting, and a fade earns very little on something
that appears once a day.

Checked at both widths and in both themes: contrast passes on every new
element, nothing scrolls sideways, and a fresh load runs the whole feature
set with no console errors.
2026-08-16 03:41:07 +05:30
Aculix Technologies 1198158abf fix: say what carry-over did, and stop the day control being a riddle
Two pieces of copy that were making the app harder to read than it needs
to be.

Carry-over happened in total silence. Open the app after a weekend and
unfamiliar tasks were sitting in Today with nothing explaining where they
came from, which reads as a bug rather than the feature it is. A line now
says how many were brought forward, on the day it happened, and it can be
dismissed.

The day control was a single button labelled with the day you were already
on, which then took you somewhere else. Clicking it was the only way to
find out what it did, and the date underneath already said where you were.
It is two options now with the current one marked, so the control states
both where you are and what the alternative is without being touched.

Also drops labelFor from dates.js along with its tests. It existed to
label that one button and nothing uses it now.
2026-08-16 03:36:16 +05:30
Aculix Technologies d97550feb8 feat: offer undo at the moment something is removed
Undo existed but nothing said so. The only route was Cmd/Ctrl+Z, which was
documented in a README nobody reads mid-task, and which does not exist on
a phone at all. On touch there was simply no way to get a deleted task
back.

Deleting a task or clearing completed ones now brings up a short-lived bar
with a real Undo button, so touch has a route for the first time. It also
shows the keyboard shortcut, which is the point: someone learns it once,
here, at the only moment they actually care.

It uses the existing tokens, sits out of the way at the bottom, clears
itself after seven seconds and goes as soon as it is used. The shortcut
badge is hidden where no keyboard exists rather than advertising a chord
nobody can press.
2026-08-16 03:31:08 +05:30
Aculix Technologies 02c8d6f6b0 feat: edit a task in place
Fixing a typo meant deleting the task and typing it again, which also cost
you its position in the list. Click the text to edit it. Enter saves,
Escape cancels, clicking away saves.

The rename rule lives in tasks.js and is tested. It refuses blank input
rather than treating it as a delete, since selecting all and hitting enter
by accident should give you your task back, and it returns the original
array untouched when nothing changed so an idle edit does not write to
storage.

The interesting part was what editing collides with. Keystrokes are
stopped from reaching the row, or Delete would remove the task you are
typing into and Alt+Arrow would reorder it mid-edit. A drag cannot start
from inside the field, and the click a drag leaves behind is swallowed, or
dropping a row would open it for editing. Switching day, deferring a task
and the midnight rollover all close an open editor, so it cannot be left
hanging over a row that is no longer there.
2026-08-16 03:28:48 +05:30
Aculix Technologies 38af47aeb6 feat: move a task between Today and Tomorrow
The app is built around two days and had no way to move anything between
them. "I won't get to this today" is the most obvious thing someone wants
from a two-day list, and the only route was deleting the task and typing
it again somewhere else.

Each row gets an arrow next to delete. On Today it sends the task forward,
on Tomorrow it points back, so one control covers both directions and the
label says which one you are getting. Alt+Right and Alt+Left do the same
from the keyboard, and each only acts in the direction that makes sense
from the day you are on.

The move itself is in src/lib/defer.js with tests, since it touches two
date keys at once and the failure modes are worth pinning down: a missing
id, the same day twice, an empty destination, and a task that somehow
already exists on the other side.
2026-08-16 03:25:35 +05:30
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
Aculix Technologies f27f1ee6d3 chore: ignore .claude
Local editor config, not something the repo needs. Also taken back out of
the earlier commit that added it.
2026-08-16 01:53:19 +05:30
Aculix Technologies 6b01e4c246 docs: tighten wording, drop the planning scratch files
Removes docs/superpowers, which held a design note and an implementation
plan. Those were working notes rather than anything the project needs, and
they don't belong in the repo.

Everything else here is wording: em dashes swapped for ordinary
punctuation across the README and the code comments, and a few sentences
straightened out.
2026-08-16 01:50:59 +05:30
Aculix Technologies 7e030722a9 fix: build once on the host arch, not once per target
The v2.0.0 image failed on linux/arm/v7. Vite bundles with Rolldown, which
has no 32-bit ARM musl binary:

  Cannot find module '@rolldown/binding-linux-arm-musleabihf'

That came in with the Vite upgrade, but the older problem is that the
builder stage was rebuilt per platform under QEMU, when its output is
static files that are identical everywhere.

Pinning the builder to $BUILDPLATFORM runs install and build once,
natively, then copies the result into each nginx image. armv7 works again,
and two emulated npm installs go away with it.
2026-08-16 01:43:07 +05:30
Aculix Technologies 6e00c0a814 ci: publish a clean :latest tag, drop cosign signing
The package page showed a sha256 hash instead of a usable tag. cosign
pushes a separate artifact per build, tagged sha256-<digest>.sig, and
because it goes up after the image it was the newest entry. Sixty-odd
nightly runs had filled the listing with them.

Dropping the signing step leaves :latest visible. The tradeoff is real:
published images are no longer signed against the sigstore log. There's no
way to keep cosign and hide its artifacts, since GHCR renders them as
ordinary versions, so it's signing against a readable package page.

Also makes the tagging explicit instead of relying on the action default.
latest=auto moves :latest onto any pushed vX.Y.Z tag.

Version goes to 2.0.0: storage moved to a new key format, migrated on
first open, and Backspace no longer deletes.

Removes the analytics tag from index.html.
2026-08-16 01:39:29 +05:30
Aculix Technologies 19e8ac2f61 fix: fluid drop instead of the whole list lurching
Letting go of a card was abrupt and pulled its neighbours with it. Frame
capture showed non-dragged rows moving about 68px after release, still
going 13 frames later.

Two things owned transform on the same elements: manual shift transforms
for the gap, and animate:flip for the reorder. On drop, flip measured a
"before" rect that already had a manual offset in it, then the order
changed and the offsets cleared in the same update, so it animated toward
a position that never existed.

One owner each now. The list reorders as the pointer crosses each
boundary rather than on release, so flip moves the displaced cards on its
own, one swap at a time, and the swaps look right while you're dragging.
Only the lifted card is positioned by hand, with flip off for it so it
stays under the pointer. By release the order is already final, and
nothing moves but that card settling.

After the change, every non-dragged row moves 0px on release and the
dropped card eases 4px into place.

Slot geometry is replaced with offsetTop and offsetHeight, layout values
that ignore transforms, so the target can't feed back into itself and
can't go stale.

Removes src/lib/drag.js and its tests. The rule they covered doesn't exist
in this design any more.
2026-08-16 01:33:51 +05:30
Aculix Technologies 00d1e211fe feat: kanban-style drag feedback with a real drop gap
The old feedback was a card faded to 0.4 opacity and a 3px line above the
target. The fade read as "disabled" rather than "held", and a thin line is
a weak signal for where something lands.

The dragged card lifts now: full opacity, accent border, raised shadow, a
small scale and tilt. Cards it passes slide by the space it vacated, which
opens a real gap at the destination. The gap can't disagree with where the
card lands, because it is where the card lands.

Also fixes something found while building it. targetIndexFor measured live
rects, but those rows carry the shift transforms the drag applies, so the
midpoints used to pick the target moved as a result of picking it and the
choice oscillated. Slot geometry is captured once at drag start, relative
to the list so scrolling mid-drag is fine.

The displacement rule moves to src/lib/drag.js with tests. Its up/down
boundaries are where off-by-ones live.
2026-08-16 01:27:44 +05:30
Aculix Technologies 143fcc205d fix: undo no longer swallowed when focus is in the task input
Cmd/Ctrl+Z did nothing in normal use. The handler bailed out whenever the
event target was an input, which was meant to protect the field's own text
undo. But the add-task input is where focus usually sits: you click it to
add a task and stay there, and on macOS clicking a button doesn't move
focus. So the guard killed undo in the one situation it exists for, right
after deleting something.

It now defers to the field only when the field actually holds text. An
empty input has nothing to restore.

The decision moves to src/lib/shortcuts.js as a pure function, because the
inline version couldn't be tested. Shift+Cmd+Z means redo and no longer
triggers an undo.
2026-08-16 01:20:55 +05:30
Aculix Technologies b837ecd67f fix: load tasks before first render, not in onMount
Removing the splash exposed something it had been covering. Tasks loaded
in onMount, so every launch rendered one frame of the empty state first.

localStorage is synchronous, so there was never a reason to wait.
Migration and rollover run during initialisation and the first render has
the real list. onMount keeps only the rollover triggers.
2026-08-16 00:16:28 +05:30
Aculix Technologies 8d6780e379 docs: correct carry-over behaviour and shortcuts
The README described migration that never ran, and listed Backspace as a
delete key.

It now says what actually happens: unfinished work carries across gaps,
completed tasks are cleared with their day, storage prunes itself, and old
keys convert on first open. Adds Cmd/Ctrl+Z and Alt+Arrow, the touch long
press, the screenshot's new path, and how to run the tests.
2026-08-16 00:14:48 +05:30
Aculix Technologies 4c0b6de712 fix: pointer-events reordering with touch and keyboard support
HTML5 drag events never fire on touch, so reordering only ever worked with
a mouse.

Pointer events handle mouse, touch and pen in one path. The entry
condition has to differ: a mouse drag starts after 8px of movement, but a
touch drag can't use movement, because a vertical swipe on a list usually
means scroll. Touch starts on a 400ms long press instead, and moving
before that cancels it and lets the page scroll. touch-action applies only
while a drag is live, plus a non-passive touchmove blocker, since
touch-action can't stop a gesture already in flight.

Alt+Up and Alt+Down reorder from the keyboard. Focus is put back on the
moved row by hand, since list reconciliation drops it and the first
version only worked once.

Also removes dataTransfer.setData('text/html', event.target), which passed
a DOM node where a string was required.
2026-08-16 00:13:50 +05:30
Aculix Technologies cf64159322 fix: keyboard-visible delete, valid list semantics, undo support
The delete button was opacity:0 and only revealed on hover, so tabbing to
it showed nothing at all. The focus outline was being drawn on an
invisible element. It shows on :focus-within now too.

The row was role="button" with tabindex=0 while containing two real
buttons, which isn't valid. Rows are <li> in a <ul> now, so the list gets
announced with its count and position, and the checkbox carries the task
text plus aria-pressed.

Backspace no longer deletes. It's the key people hit meaning "go back",
and there was no way to get the task back afterwards. Delete still works,
and Cmd/Ctrl+Z reverses it.

Enter and Escape move from window to the input. On window, pressing Enter
with a task focused toggled that task and also added whatever was sitting
in the input.
2026-08-16 00:09:58 +05:30
Aculix Technologies 756b4254b4 perf: drop lottie, remove splash, fix theme flash
Bundle goes from 337 kB to 52 kB, or 89 kB to 20 kB gzipped. Almost all of
it was lottie-web, imported at the top level to draw one decorative empty
state, so every visitor paid 305 kB whether they ever saw it or not. It's
an inline SVG now, and it respects prefers-reduced-motion.

The 500ms splash is gone. There was no async work behind it.

Dark mode is applied by a small script in the head before first paint, so
dark-mode users stop getting a white flash on load. The theme class moves
to <html>, and App.svelte reads its initial state from that class instead
of re-reading storage after mount.

publicDir moves from assets/ to public/, which stops shipping the 200 kB
README screenshot to everyone.
2026-08-16 00:02:30 +05:30
Aculix Technologies 4857f1cc26 chore: upgrade to svelte 5 and vite 8
Svelte 4.2 to 5.56, Vite 5 to 8, vite-plugin-svelte 3 to 7. The old tree
wouldn't resolve incrementally, because a stale vite-plugin-svelte-inspector
pinned the v3 peer, so this is a clean reinstall.

Clears all 7 npm audit advisories, including a high-severity Rollup path
traversal.

Reactivity moves to runes: $state, $derived, $effect. Event directives
become properties, and main.js uses mount().

Also drops inputElement, a bind:this target nothing ever read.
2026-08-15 23:59:28 +05:30
Aculix Technologies 50509a9cc1 refactor: rewire App.svelte onto extracted lib modules
App.svelte is a view layer now. Storage, dates, rollover and task
operations come from src/lib, and selectedDate (a toDateString value)
becomes selectedKey (an ISO key).

Rollover runs on four triggers, because no single one covers everything:
mount, a self-rescheduling midnight timer, visibilitychange, and window
focus. The timer handles a pinned tab crossing midnight on its own. The
other two handle machine sleep, where timers don't reliably fire.

Persistence goes through one setTasks path so the stored list can't drift
from the visible one.

Checked in the browser against seeded legacy data across three days: keys
migrate and collapse correctly, unfinished tasks carry while completed
ones drop, and a simulated midnight updates an open tab with no reload.
2026-08-15 23:56:53 +05:30
Aculix Technologies f0fd6c1a65 feat: add pure task operations and bounded undo stack
Task operations become pure functions over arrays.

Ids move from Date.now() to crypto.randomUUID(), with a fallback for
non-secure contexts, since people do self-host this over plain HTTP on a
LAN.

Undo gets its own module so tasks.js stays pure. It covers single deletes
and clear-completed batches, and clamps indices because the list can
change between recording an entry and undoing it.
2026-08-15 23:52:33 +05:30
Aculix Technologies 5ce5b13ecd feat: add day-rollover with multi-day carry-forward
Replaces checkAndMigrateTasks, which never ran. Its guard compared
currentDate, set from new Date() at init, against a fresh new Date() a few
milliseconds later in onMount, so it was never true. Unfinished tasks have
been sitting under old date keys ever since.

The rule now: unfinished tasks from every prior day move into today,
oldest first, ahead of whatever is already there. Completed ones are
dropped and the old key deleted. It spans gaps, which the original never
did even in theory, since it only looked one day back.

now is a parameter. That's the part that makes the boundary testable.
2026-08-15 23:51:36 +05:30
Aculix Technologies d69c68ccef feat: add resilient storage module with legacy key migration
A factory over an injectable backend, so persistence can be tested without
a DOM.

Fixes the unguarded JSON.parse that would white-screen the app on one bad
value, and falls back to memory when localStorage itself throws (Safari
private mode, full quota).

migrateLegacyKeys converts old "Sat Aug 15 2026" keys in place. It's
idempotent, and it leaves anything it can't parse alone rather than
deleting it.
2026-08-15 23:50:20 +05:30
Aculix Technologies e81e7ef8e7 feat: add date-key module with local-time formatting
Switches task keys to ISO YYYY-MM-DD. They sort, so finding every day
before today is a string compare instead of re-parsing each key.

Two things worth knowing. fromKey builds from date parts rather than
parsing a bare ISO string, which gets read as UTC and lands on the wrong
day west of Greenwich. addDays goes through setDate, so it stays on the
same calendar day across a DST change instead of adding a flat 24 hours.
2026-08-15 23:49:29 +05:30
Aculix Technologies d4ddceaf73 chore: add vitest, commit the lockfile, fix gitignore and docker install
Groundwork before pulling logic out of App.svelte.

Vitest runs in a node environment. Nothing under test needs a DOM, because
every module takes its dependencies as arguments.

package-lock.json is tracked now and the Dockerfile installs with npm ci,
so the nightly rebuild can't quietly land on different versions.

.gitignore: .DS_Store was anchored to the root and missed nested copies,
and dist/ wasn't ignored at all.
2026-08-15 23:48:55 +05:30
Aculix Technologies 056f9fc562 Refactor date display logic in App.svelte to use reactive variable for improved readability 2025-10-16 17:32:02 +05:30
Aculix Technologies 2697a81f72 Enhance README with app description and screenshot 2025-10-16 14:49:20 +05:30
Aculix Technologies cfff2eec42 Update README 2025-10-16 14:35:11 +05:30
Aculix Technologies 345f9ee347 Update README 2025-10-16 14:29:21 +05:30
Aculix Technologies LLP 5fe1669b35 Create docker-publish.yml 2025-10-16 14:18:46 +05:30
Aculix Technologies 876ba3c835 first commit 2025-10-16 14:16:01 +05:30
36 changed files with 4562 additions and 8378 deletions
+11 -26
View File
@@ -27,9 +27,6 @@ jobs:
permissions: permissions:
contents: read contents: read
packages: write packages: write
# This is used to complete the identity challenge
# with sigstore/fulcio when running outside of PRs.
id-token: write
steps: steps:
- name: Checkout repository - name: Checkout repository
@@ -39,14 +36,6 @@ jobs:
- name: Set up QEMU - name: Set up QEMU
uses: docker/setup-qemu-action@68827325e0b33c7199eb31dd4e31fbe9023e06e3 # v3.0.0 uses: docker/setup-qemu-action@68827325e0b33c7199eb31dd4e31fbe9023e06e3 # v3.0.0
# Install the cosign tool except on PR
# https://github.com/sigstore/cosign-installer
- name: Install cosign
if: github.event_name != 'pull_request'
uses: sigstore/cosign-installer@59acb6260d9c0ba8f4a2f9d9b48431a222b68e20 #v3.5.0
with:
cosign-release: 'v2.2.4'
# Set up BuildKit Docker container builder to be able to build # Set up BuildKit Docker container builder to be able to build
# multi-platform images and export cache # multi-platform images and export cache
# https://github.com/docker/setup-buildx-action # https://github.com/docker/setup-buildx-action
@@ -70,6 +59,17 @@ jobs:
uses: docker/metadata-action@96383f45573cb7f253c731d3b3ab81c87ef81934 # v5.0.0 uses: docker/metadata-action@96383f45573cb7f253c731d3b3ab81c87ef81934 # v5.0.0
with: with:
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }} images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
# latest=auto also moves :latest onto any pushed vX.Y.Z tag, so the
# tag people actually pull tracks the newest release as well as main.
flavor: |
latest=auto
tags: |
type=raw,value=latest,enable={{is_default_branch}}
type=ref,event=branch
type=ref,event=pr
type=semver,pattern={{version}}
type=semver,pattern={{major}}.{{minor}}
type=semver,pattern={{major}}
# Build and push Docker image with Buildx (don't push on PR) # Build and push Docker image with Buildx (don't push on PR)
# https://github.com/docker/build-push-action # https://github.com/docker/build-push-action
@@ -84,18 +84,3 @@ jobs:
labels: ${{ steps.meta.outputs.labels }} labels: ${{ steps.meta.outputs.labels }}
cache-from: type=gha cache-from: type=gha
cache-to: type=gha,mode=max cache-to: type=gha,mode=max
# Sign the resulting Docker image digest except on PRs.
# This will only write to the public Rekor transparency log when the Docker
# repository is public to avoid leaking data. If you would like to publish
# transparency data even for private images, pass --force to cosign below.
# https://github.com/sigstore/cosign
- name: Sign the published Docker image
if: ${{ github.event_name != 'pull_request' }}
env:
# https://docs.github.com/en/actions/security-guides/security-hardening-for-github-actions#using-an-intermediate-environment-variable
TAGS: ${{ steps.meta.outputs.tags }}
DIGEST: ${{ steps.build-and-push.outputs.digest }}
# This step uses the identity token to provision an ephemeral certificate
# against the sigstore community Fulcio instance.
run: echo "${TAGS}" | xargs -I {} cosign sign --yes {}@${DIGEST}
+3 -1
View File
@@ -1,2 +1,4 @@
/.dockerignore .DS_Store
/node_modules /node_modules
/dist
/.claude
+28 -3
View File
@@ -1,15 +1,40 @@
FROM node:20-alpine # Build stage
#
# Pinned to the *build host's* architecture, not the target's. The output is
# static HTML, CSS and JS — byte-identical whatever the image will eventually
# run on — so emulating the target here buys nothing and costs a QEMU-emulated
# npm install and bundle on every extra platform.
#
# It is also required for linux/arm/v7: Vite bundles with Rolldown, which ships
# no 32-bit ARM musl binary, so `npm run build` cannot run inside that image at
# all.
FROM --platform=$BUILDPLATFORM node:20-alpine AS builder
WORKDIR /app WORKDIR /app
# Copy package files # Copy package files
COPY package*.json ./ COPY package*.json ./
# Install dependencies with proper optional dependency handling for Alpine/musl # Install from the committed lockfile so builds are reproducible
RUN npm install --include=optional RUN npm ci --include=optional
# Copy source code # Copy source code
COPY . . COPY . .
# Build the application # Build the application
RUN npm run build RUN npm run build
# Production stage — this one is built per target platform.
FROM nginx:alpine
# Copy built files from builder stage
COPY --from=builder /app/dist /usr/share/nginx/html
# Copy nginx configuration (if needed)
# COPY nginx.conf /etc/nginx/conf.d/default.conf
# Expose port 80
EXPOSE 80
# Start nginx
CMD ["nginx", "-g", "daemon off;"]
+82 -16
View File
@@ -1,7 +1,9 @@
# Negotium # Negotium - Minimalist To Do App
A beautiful, minimal to-do list application featuring smooth animations, intelligent date management, and a modern design that helps you stay organized and productive. A beautiful, minimal to-do list application featuring smooth animations, intelligent date management, and a modern design that helps you stay organized and productive.
![Negotium Screenshot](docs/screenshot.png)
## 💭 Why Negotium? ## 💭 Why Negotium?
While powerful tools like Trello and Vikunja excel at managing complex projects and long-term planning, sometimes you just need a simple, focused space for your daily tasks. That's why I built Negotium, a straightforward to-do list for today and tomorrow. Nothing more, nothing less. While powerful tools like Trello and Vikunja excel at managing complex projects and long-term planning, sometimes you just need a simple, focused space for your daily tasks. That's why I built Negotium, a straightforward to-do list for today and tomorrow. Nothing more, nothing less.
@@ -12,20 +14,47 @@ Built with Svelte for speed and simplicity. No overwhelming features, no endless
### Core Functionality ### Core Functionality
-**Add, complete, and delete tasks** with smooth animations -**Add, complete, and delete tasks** with smooth animations
- ↩️ **Undo** - Deleted or moved something by mistake? An Undo button appears, and `Cmd/Ctrl+Z` works too. The task returns to its original position
- 📅 **Today & Tomorrow lists** - Plan ahead with separate task lists - 📅 **Today & Tomorrow lists** - Plan ahead with separate task lists
- 🔄 **Automatic task migration** - Tomorrow's tasks automatically move to Today when a new day begins - 🔄 **Unfinished work carries over** - When a new day begins, tasks you didn't finish move to Today; completed ones are cleared away
- 🎯 **Drag and drop reordering** - Organize tasks by dragging them into position - ✏️ **Edit a task** - Fix a typo without deleting and retyping it
- ➡️ **Push a task to tomorrow** - Didn't get to it? Move it across without retyping it
- 🎯 **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 remaining and completed tasks 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
### Installation ### Quick Start with Docker (Recommended)
Pull and run the pre-built Docker image:
```bash
# Pull the image
docker pull ghcr.io/aculix/negotium:main
# Run the container
docker run -d -p 3000:80 --name negotium ghcr.io/aculix/negotium:main
```
Then open `http://localhost:3000` in your browser.
To stop the container:
```bash
docker stop negotium
docker rm negotium
```
### Manual Installation
If you prefer to run the application locally without Docker:
1. Clone the repository: 1. Clone the repository:
```bash ```bash
git clone <repository-url> git clone <repository-url>
cd simple-do cd negotium
``` ```
2. Install dependencies: 2. Install dependencies:
@@ -40,7 +69,7 @@ npm run dev
4. Open `http://localhost:3000` in your browser 4. Open `http://localhost:3000` in your browser
### Build for Production #### Build for Production
```bash ```bash
npm run build npm run build
@@ -48,20 +77,48 @@ npm run build
The optimized files will be in the `dist` directory. The optimized files will be in the `dist` directory.
#### Running the Tests
```bash
npm test
```
The date, storage, rollover, task and undo logic lives in `src/lib/` as plain modules that take the current time as an argument, so behaviour at a day boundary is covered by ordinary unit tests rather than by waiting for midnight.
## 📖 How to Use ## 📖 How to Use
### 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 - **Edit a task**: Click the pencil on the task. Enter saves, Escape cancels, and clicking away saves too
- **Reorder tasks**: Click and drag any task to a new position - **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
- **Clear input**: Press Escape to clear the input field - **Undo**: An Undo button appears for a few seconds after a task is deleted, cleared or moved to another day. `Cmd/Ctrl+Z` does the same. Either way the task returns to where it was
- **Move a task to Tomorrow**: Click the arrow on the task, or press `Alt+→`. From Tomorrow, the arrow points back and `Alt+←` returns it to Today
- **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
### Date Management ### Date Management
- **Switch between Today and Tomorrow**: Click the date button in the header - **Switch between Today and Tomorrow**: Pick either one in the header. The current day is the highlighted one
- **Plan ahead**: Add tasks to Tomorrow's list before you need them - **Plan ahead**: Add tasks to Tomorrow's list before you need them
- **Automatic migration**: When a new day begins, Tomorrow's tasks automatically become Today's tasks
- **Separate lists**: Today and Tomorrow maintain independent task lists - **Separate lists**: Today and Tomorrow maintain independent task lists
- **Carry-over**: When a new day begins, whatever you didn't finish moves into Today, and the app tells you how many tasks it brought forward so they don't look like they appeared from nowhere. 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.
### 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
@@ -70,17 +127,26 @@ The optimized files will be in the `dist` directory.
### Keyboard Shortcuts ### Keyboard Shortcuts
- **Enter**: Add task (when input is focused) - **Enter**: Add task (when input is focused)
- **Escape**: Clear input field - **Escape**: Clear input field (when input is focused)
- **Space/Enter**: Toggle task completion (when task is focused) - **Space/Enter**: Toggle task completion (when a task's checkbox is focused)
- **Delete/Backspace**: Delete task (when task is focused) - **Delete**: Delete the focused task
- **Cmd/Ctrl+Z**: Undo the last delete, clear-completed, or move between days
- **Alt+↑ / Alt+↓**: Move the focused task up or down
- **Alt+→ / Alt+←**: Send the focused task to Tomorrow, or bring it back to Today
Backspace no longer deletes a task. It is too easily pressed by accident, and deletions used to be unrecoverable.
## 💾 Data Storage ## 💾 Data Storage
All data is stored locally in your browser using localStorage: All data is stored locally in your browser using localStorage:
- **Tasks**: Separate storage keys for each date (`negotium-tasks-<date>`) - **Tasks**: One key per date, in ISO form (`negotium-tasks-2026-08-15`)
- **Theme**: Your theme preference (`negotium-theme`) - **Theme**: Your theme preference (`negotium-theme`)
- **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
- **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.
## 📄 License ## 📄 License
File diff suppressed because it is too large Load Diff
Binary file not shown.

After

Width:  |  Height:  |  Size: 195 KiB

+25 -4
View File
@@ -2,10 +2,33 @@
<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>
// 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
// source of truth.
(function () {
try {
var stored = localStorage.getItem('negotium-theme');
var dark = stored
? stored === 'dark'
: window.matchMedia('(prefers-color-scheme: dark)').matches;
if (dark) document.documentElement.classList.add('dark');
} catch (e) {
// Storage unavailable; fall through to the light default.
}
})();
</script>
<style> <style>
/* Prevent flash of unstyled content */ /* Prevent flash of unstyled content */
body { body {
@@ -14,14 +37,12 @@
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
background-color: #F8FAFB; background-color: #F8FAFB;
color: #1A1A1A; color: #1A1A1A;
transition: background-color 300ms ease, color 300ms ease;
} }
.dark { html.dark body {
background-color: #121212; background-color: #121212;
color: #E0E0E0; color: #E0E0E0;
} }
</style> </style>
</head> </head>
<body> <body>
+1453
View File
File diff suppressed because it is too large Load Diff
+9 -9
View File
@@ -1,18 +1,21 @@
{ {
"name": "negotium-todo", "name": "negotium-todo",
"version": "1.0.0", "version": "2.2.2",
"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",
"scripts": { "scripts": {
"dev": "vite", "dev": "vite",
"build": "vite build", "build": "vite build",
"preview": "vite preview" "preview": "vite preview",
"test": "vitest run",
"test:watch": "vitest"
}, },
"devDependencies": { "devDependencies": {
"@sveltejs/vite-plugin-svelte": "^3.0.0", "@sveltejs/vite-plugin-svelte": "^7.3.0",
"svelte": "^4.2.0", "svelte": "^5.56.9",
"vite": "^5.0.0" "vite": "^8.2.1",
"vitest": "^4.1.10"
}, },
"keywords": [ "keywords": [
"todo", "todo",
@@ -21,8 +24,5 @@
"minimal" "minimal"
], ],
"author": "Negotium", "author": "Negotium",
"license": "MIT", "license": "MIT"
"dependencies": {
"lottie-web": "^5.13.0"
}
} }
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

View File

Before

Width:  |  Height:  |  Size: 908 B

After

Width:  |  Height:  |  Size: 908 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))
})
+682 -219
View File
File diff suppressed because it is too large Load Diff
+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'))
})
})
+38
View File
@@ -0,0 +1,38 @@
const KEY_PATTERN = /^\d{4}-\d{2}-\d{2}$/
/** A `Date` as a local-time `YYYY-MM-DD` key. Sortable, which is what the
* rollover logic relies on to find every day earlier than today. */
export function toKey(date) {
const year = date.getFullYear()
const month = String(date.getMonth() + 1).padStart(2, '0')
const day = String(date.getDate()).padStart(2, '0')
return `${year}-${month}-${day}`
}
/** Local midnight for a key. Built from parts rather than `new Date(key)`,
* which parses bare ISO dates as UTC and shifts the day west of Greenwich. */
export function fromKey(key) {
const [year, month, day] = key.split('-').map(Number)
return new Date(year, month - 1, day)
}
/** Goes through `setDate`, so it stays on the same calendar day across a DST
* boundary rather than adding a fixed 24 hours. */
export function addDays(date, amount) {
const next = new Date(date.getTime())
next.setDate(next.getDate() + amount)
return next
}
export function isKey(value) {
return KEY_PATTERN.test(value)
}
export function formatLong(dateKey) {
return fromKey(dateKey).toLocaleDateString('en-US', {
weekday: 'long',
year: 'numeric',
month: 'long',
day: 'numeric',
})
}
+67
View File
@@ -0,0 +1,67 @@
import { describe, it, expect } from 'vitest'
import { toKey, fromKey, addDays, isKey, formatLong } from './dates.js'
describe('toKey', () => {
it('formats a date as local YYYY-MM-DD', () => {
expect(toKey(new Date(2026, 7, 15))).toBe('2026-08-15')
})
it('zero-pads single-digit months and days', () => {
expect(toKey(new Date(2026, 0, 5))).toBe('2026-01-05')
})
it('uses local time, not UTC', () => {
// 23:30 local on the 15th must not roll forward to the 16th.
expect(toKey(new Date(2026, 7, 15, 23, 30))).toBe('2026-08-15')
})
})
describe('fromKey', () => {
it('returns local midnight for the key', () => {
const date = fromKey('2026-08-15')
expect(date.getFullYear()).toBe(2026)
expect(date.getMonth()).toBe(7)
expect(date.getDate()).toBe(15)
expect(date.getHours()).toBe(0)
})
it('round-trips with toKey', () => {
expect(toKey(fromKey('2026-08-15'))).toBe('2026-08-15')
})
})
describe('addDays', () => {
it('advances across a month boundary', () => {
expect(toKey(addDays(new Date(2026, 7, 31), 1))).toBe('2026-09-01')
})
it('advances across a year boundary', () => {
expect(toKey(addDays(new Date(2026, 11, 31), 1))).toBe('2027-01-01')
})
it('does not mutate its argument', () => {
const date = new Date(2026, 7, 15)
addDays(date, 5)
expect(toKey(date)).toBe('2026-08-15')
})
})
describe('isKey', () => {
it('accepts ISO date keys', () => {
expect(isKey('2026-08-15')).toBe(true)
})
it('rejects legacy toDateString keys', () => {
expect(isKey('Sat Aug 15 2026')).toBe(false)
})
it('rejects arbitrary strings', () => {
expect(isKey('not-a-date')).toBe(false)
})
})
describe('formatLong', () => {
it('renders the long-form date used in the header', () => {
expect(formatLong('2026-08-15')).toBe('Saturday, August 15, 2026')
})
})
+38
View File
@@ -0,0 +1,38 @@
/**
* Moves one task from one day to another and returns what is left on the
* source day.
*
* This is the verb a two-day app is built around: "not today, tomorrow". Both
* days are written before returning, so a caller only has to reload the day it
* is showing.
*
* A task already sitting at the destination is not duplicated. It still leaves
* the source day, which is what someone dragging a stray copy around would
* expect.
*
* `insertAt` places the task at a position rather than at the end. Undoing a
* move needs it: a task sent to Tomorrow should come back to the row it left,
* not to the bottom of the list.
*/
export function moveTaskToDay(storage, fromKey, toKey, taskId, insertAt = null) {
const source = storage.loadTasks(fromKey)
if (fromKey === toKey) return source
const moving = source.find(task => task.id === taskId)
if (!moving) return source
const remaining = source.filter(task => task.id !== taskId)
const destination = storage.loadTasks(toKey)
const alreadyThere = destination.some(task => task.id === taskId)
storage.saveTasks(fromKey, remaining)
if (!alreadyThere) {
const next = [...destination]
const at = insertAt === null ? next.length : Math.max(0, Math.min(insertAt, next.length))
next.splice(at, 0, moving)
storage.saveTasks(toKey, next)
}
return remaining
}
+119
View File
@@ -0,0 +1,119 @@
import { describe, it, expect } from 'vitest'
import { createStorage, createMemoryStore } from './storage.js'
import { moveTaskToDay } from './defer.js'
const task = (id, text = id) => ({ id, text, completed: false, 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 TODAY = '2026-08-16'
const TOMORROW = '2026-08-17'
describe('moveTaskToDay', () => {
it('removes the task from the source day', () => {
const storage = setup({ [TODAY]: [task('a'), task('b')] })
moveTaskToDay(storage, TODAY, TOMORROW, 'a')
expect(storage.loadTasks(TODAY).map(t => t.id)).toEqual(['b'])
})
it('appends the task to the destination day', () => {
const storage = setup({ [TODAY]: [task('a')], [TOMORROW]: [task('existing')] })
moveTaskToDay(storage, TODAY, TOMORROW, 'a')
expect(storage.loadTasks(TOMORROW).map(t => t.id)).toEqual(['existing', 'a'])
})
it('works when the destination day has nothing stored yet', () => {
const storage = setup({ [TODAY]: [task('a')] })
moveTaskToDay(storage, TODAY, TOMORROW, 'a')
expect(storage.loadTasks(TOMORROW).map(t => t.id)).toEqual(['a'])
})
it('carries the task across unchanged', () => {
const original = { id: 'a', text: 'buy milk', completed: true, createdAt: 42 }
const storage = setup({ [TODAY]: [original] })
moveTaskToDay(storage, TODAY, TOMORROW, 'a')
expect(storage.loadTasks(TOMORROW)[0]).toEqual(original)
})
it('returns the source days remaining tasks', () => {
const storage = setup({ [TODAY]: [task('a'), task('b')] })
const result = moveTaskToDay(storage, TODAY, TOMORROW, 'a')
expect(result.map(t => t.id)).toEqual(['b'])
})
it('leaves the other tasks in their original order', () => {
const storage = setup({ [TODAY]: [task('a'), task('b'), task('c')] })
moveTaskToDay(storage, TODAY, TOMORROW, 'b')
expect(storage.loadTasks(TODAY).map(t => t.id)).toEqual(['a', 'c'])
})
it('is a no-op for an id that is not there', () => {
const storage = setup({ [TODAY]: [task('a')] })
const result = moveTaskToDay(storage, TODAY, TOMORROW, 'zzz')
expect(result.map(t => t.id)).toEqual(['a'])
expect(storage.loadTasks(TOMORROW)).toEqual([])
})
it('is a no-op when source and destination are the same day', () => {
const storage = setup({ [TODAY]: [task('a')] })
const result = moveTaskToDay(storage, TODAY, TODAY, 'a')
expect(result.map(t => t.id)).toEqual(['a'])
expect(storage.loadTasks(TODAY).map(t => t.id)).toEqual(['a'])
})
it('moves back the other way just as well', () => {
const storage = setup({ [TOMORROW]: [task('a')], [TODAY]: [task('b')] })
moveTaskToDay(storage, TOMORROW, TODAY, 'a')
expect(storage.loadTasks(TODAY).map(t => t.id)).toEqual(['b', 'a'])
expect(storage.loadTasks(TOMORROW)).toEqual([])
})
it('does not duplicate a task already present at the destination', () => {
const storage = setup({ [TODAY]: [task('a')], [TOMORROW]: [task('a')] })
moveTaskToDay(storage, TODAY, TOMORROW, 'a')
expect(storage.loadTasks(TOMORROW).map(t => t.id)).toEqual(['a'])
expect(storage.loadTasks(TODAY)).toEqual([])
})
})
describe('moveTaskToDay with an insert position', () => {
it('inserts at the given index rather than appending', () => {
const storage = setup({ [TOMORROW]: [task('x')], [TODAY]: [task('a'), task('b'), task('c')] })
moveTaskToDay(storage, TOMORROW, TODAY, 'x', 1)
expect(storage.loadTasks(TODAY).map(t => t.id)).toEqual(['a', 'x', 'b', 'c'])
})
it('puts a task back at the front', () => {
const storage = setup({ [TOMORROW]: [task('x')], [TODAY]: [task('a')] })
moveTaskToDay(storage, TOMORROW, TODAY, 'x', 0)
expect(storage.loadTasks(TODAY).map(t => t.id)).toEqual(['x', 'a'])
})
it('clamps an index past the end', () => {
const storage = setup({ [TOMORROW]: [task('x')], [TODAY]: [task('a')] })
moveTaskToDay(storage, TOMORROW, TODAY, 'x', 99)
expect(storage.loadTasks(TODAY).map(t => t.id)).toEqual(['a', 'x'])
})
it('still appends when no index is given', () => {
const storage = setup({ [TOMORROW]: [task('x')], [TODAY]: [task('a')] })
moveTaskToDay(storage, TOMORROW, TODAY, 'x')
expect(storage.loadTasks(TODAY).map(t => t.id)).toEqual(['a', 'x'])
})
it('round-trips a defer back to where it started', () => {
const storage = setup({ [TODAY]: [task('a'), task('b'), task('c')] })
moveTaskToDay(storage, TODAY, TOMORROW, 'b')
expect(storage.loadTasks(TODAY).map(t => t.id)).toEqual(['a', 'c'])
moveTaskToDay(storage, TOMORROW, TODAY, 'b', 1)
expect(storage.loadTasks(TODAY).map(t => t.id)).toEqual(['a', 'b', 'c'])
expect(storage.loadTasks(TOMORROW)).toEqual([])
})
})
+52
View File
@@ -0,0 +1,52 @@
import { toKey } from './dates.js'
/**
* Applies the day-boundary rule and returns today's resulting task list.
*
* For every stored day earlier than `now`: unfinished tasks are carried into
* today (oldest day first, within-day order preserved, ahead of anything
* already in today), completed tasks are discarded, and the old key is
* deleted. Pruning is therefore a side effect of carrying, so there is no
* separate retention policy. Future keys, meaning Tomorrow, are never touched.
*
* `now` is a parameter rather than a `new Date()` call so the boundary is
* testable. This replaces `checkAndMigrateTasks`, whose guard compared a
* timestamp against itself and so never once executed.
*/
export function rollover(storage, now) {
const todayKey = toKey(now)
// listTaskKeys yields only well-formed ISO keys, which is what makes this
// string comparison safe, and is why the key format changed.
const pastKeys = storage
.listTaskKeys()
.filter((key) => key < todayKey)
.sort()
if (pastKeys.length === 0) return storage.loadTasks(todayKey)
const carried = []
for (const key of pastKeys) {
carried.push(...storage.loadTasks(key).filter((task) => !task.completed))
storage.removeTasks(key)
}
const merged = [...carried, ...storage.loadTasks(todayKey)]
// Only persist when something actually moved. Prior days holding nothing but
// completed tasks still get pruned above, but today's list is unchanged, and
// rollover runs on every focus and visibility change.
if (carried.length > 0) storage.saveTasks(todayKey, merged)
return merged
}
/**
* Milliseconds until the next local midnight, for scheduling the rollover
* timer. Built from local calendar parts, so a spring-forward day correctly
* yields 23 hours rather than a flat 24.
*/
export function msUntilNextMidnight(now) {
const next = new Date(now.getFullYear(), now.getMonth(), now.getDate() + 1)
return Math.max(1000, next.getTime() - now.getTime())
}
+145
View File
@@ -0,0 +1,145 @@
import { describe, it, expect } from 'vitest'
import { createStorage, createMemoryStore } from './storage.js'
import { rollover, msUntilNextMidnight } from './rollover.js'
const task = (id, completed = false) => ({ id, text: id, completed })
const setup = (seed = {}) => {
const backend = createMemoryStore()
for (const [dateKey, tasks] of Object.entries(seed)) {
backend.setItem(`negotium-tasks-${dateKey}`, JSON.stringify(tasks))
}
return createStorage(backend)
}
describe('rollover', () => {
it('is a no-op when only today has tasks', () => {
const storage = setup({ '2026-08-15': [task('a')] })
const result = rollover(storage, new Date(2026, 7, 15, 9, 0))
expect(result.map((t) => t.id)).toEqual(['a'])
expect(storage.listTaskKeys()).toEqual(['2026-08-15'])
})
it('carries unfinished tasks forward from yesterday', () => {
const storage = setup({ '2026-08-14': [task('old')] })
const result = rollover(storage, new Date(2026, 7, 15, 9, 0))
expect(result.map((t) => t.id)).toEqual(['old'])
expect(storage.loadTasks('2026-08-15').map((t) => t.id)).toEqual(['old'])
})
it('drops completed tasks from prior days', () => {
const storage = setup({ '2026-08-14': [task('done', true), task('open')] })
const result = rollover(storage, new Date(2026, 7, 15, 9, 0))
expect(result.map((t) => t.id)).toEqual(['open'])
})
it('deletes prior-day keys after carrying', () => {
const storage = setup({ '2026-08-14': [task('old')] })
rollover(storage, new Date(2026, 7, 15, 9, 0))
expect(storage.listTaskKeys()).toEqual(['2026-08-15'])
})
it('spans a multi-day gap, oldest day first', () => {
const storage = setup({
'2026-08-12': [task('mon')],
'2026-08-13': [task('tue')],
'2026-08-14': [task('wed')],
})
const result = rollover(storage, new Date(2026, 7, 15, 9, 0))
expect(result.map((t) => t.id)).toEqual(['mon', 'tue', 'wed'])
})
it('preserves within-day order', () => {
const storage = setup({ '2026-08-14': [task('first'), task('second'), task('third')] })
const result = rollover(storage, new Date(2026, 7, 15, 9, 0))
expect(result.map((t) => t.id)).toEqual(['first', 'second', 'third'])
})
it('places carried tasks above tasks already in today', () => {
const storage = setup({
'2026-08-14': [task('carried')],
'2026-08-15': [task('existing')],
})
const result = rollover(storage, new Date(2026, 7, 15, 9, 0))
expect(result.map((t) => t.id)).toEqual(['carried', 'existing'])
})
it('never touches future keys', () => {
const storage = setup({
'2026-08-15': [task('today')],
'2026-08-16': [task('tomorrow')],
})
rollover(storage, new Date(2026, 7, 15, 9, 0))
expect(storage.loadTasks('2026-08-16').map((t) => t.id)).toEqual(['tomorrow'])
})
it('handles prior days that hold only completed tasks', () => {
const storage = setup({ '2026-08-14': [task('done', true)] })
const result = rollover(storage, new Date(2026, 7, 15, 9, 0))
expect(result).toEqual([])
expect(storage.listTaskKeys()).toEqual([])
})
it('returns an empty list when nothing is stored at all', () => {
const storage = setup()
expect(rollover(storage, new Date(2026, 7, 15, 9, 0))).toEqual([])
})
it('carries across a month boundary', () => {
const storage = setup({ '2026-07-31': [task('july')] })
const result = rollover(storage, new Date(2026, 7, 1, 9, 0))
expect(result.map((t) => t.id)).toEqual(['july'])
})
it('carries across a year boundary', () => {
const storage = setup({ '2026-12-31': [task('nye')] })
const result = rollover(storage, new Date(2027, 0, 1, 9, 0))
expect(result.map((t) => t.id)).toEqual(['nye'])
})
it('is idempotent when run twice on the same day', () => {
const storage = setup({ '2026-08-14': [task('old')] })
const now = new Date(2026, 7, 15, 9, 0)
rollover(storage, now)
const second = rollover(storage, now)
expect(second.map((t) => t.id)).toEqual(['old'])
expect(storage.listTaskKeys()).toEqual(['2026-08-15'])
})
it('leaves an unparseable legacy key in place rather than treating it as past', () => {
const backend = createMemoryStore()
backend.setItem('negotium-tasks-not-a-date', '[{"id":"x"}]')
const storage = createStorage(backend)
rollover(storage, new Date(2026, 7, 15, 9, 0))
expect(backend.getItem('negotium-tasks-not-a-date')).toBe('[{"id":"x"}]')
})
})
describe('msUntilNextMidnight', () => {
it('counts down to the next local midnight', () => {
expect(msUntilNextMidnight(new Date(2026, 7, 15, 23, 0, 0))).toBe(60 * 60 * 1000)
})
it('returns a full day just after midnight', () => {
expect(msUntilNextMidnight(new Date(2026, 7, 15, 0, 0, 0))).toBe(24 * 60 * 60 * 1000)
})
it('never returns a non-positive value', () => {
expect(msUntilNextMidnight(new Date(2026, 7, 15, 23, 59, 59, 999))).toBeGreaterThan(0)
})
it('lands exactly on the next calendar day', () => {
const now = new Date(2026, 7, 15, 17, 42, 13)
const landing = new Date(now.getTime() + msUntilNextMidnight(now))
expect(landing.getDate()).toBe(16)
expect(landing.getHours()).toBe(0)
expect(landing.getMinutes()).toBe(0)
})
it('lands on the next calendar day across a month boundary', () => {
const now = new Date(2026, 7, 31, 20, 0, 0)
const landing = new Date(now.getTime() + msUntilNextMidnight(now))
expect(landing.getMonth()).toBe(8)
expect(landing.getDate()).toBe(1)
})
})
+32
View File
@@ -0,0 +1,32 @@
const TEXT_FIELDS = new Set(['INPUT', 'TEXTAREA'])
/**
* Whether a keydown should trigger task undo.
*
* The subtlety is the text field. Deferring to a focused field's own undo
* sounds right, but the add-task input is where focus normally sits. You
* click it to add a task and focus stays there, and on macOS clicking a
* button does not move focus. Guarding on focus alone therefore disables
* undo in the one situation it is needed: right after deleting a task.
*
* So it defers only when the field actually holds text worth undoing. An
* empty input has nothing for the browser to restore, and task undo wins.
*/
export function shouldHandleUndo(event) {
const isUndoChord =
(event.metaKey || event.ctrlKey) &&
!event.shiftKey && // Shift+Cmd+Z means redo, which this app does not have.
!event.altKey &&
typeof event.key === 'string' &&
event.key.toLowerCase() === 'z'
if (!isUndoChord) return false
const target = event.target
if (!target) return true
if (target.isContentEditable) return false
if (TEXT_FIELDS.has(target.tagName) && target.value) return false
return true
}
+78
View File
@@ -0,0 +1,78 @@
import { describe, it, expect } from 'vitest'
import { shouldHandleUndo } from './shortcuts.js'
const evt = (overrides = {}) => ({
metaKey: false,
ctrlKey: false,
shiftKey: false,
altKey: false,
key: 'z',
target: { tagName: 'BODY' },
...overrides,
})
describe('shouldHandleUndo', () => {
it('accepts Cmd+Z', () => {
expect(shouldHandleUndo(evt({ metaKey: true }))).toBe(true)
})
it('accepts Ctrl+Z', () => {
expect(shouldHandleUndo(evt({ ctrlKey: true }))).toBe(true)
})
it('accepts an uppercase key from caps lock', () => {
expect(shouldHandleUndo(evt({ metaKey: true, key: 'Z' }))).toBe(true)
})
it('rejects Z with no modifier', () => {
expect(shouldHandleUndo(evt())).toBe(false)
})
it('rejects a different letter', () => {
expect(shouldHandleUndo(evt({ metaKey: true, key: 'y' }))).toBe(false)
})
it('rejects Shift+Cmd+Z, which means redo', () => {
expect(shouldHandleUndo(evt({ metaKey: true, shiftKey: true }))).toBe(false)
})
it('rejects Alt+Cmd+Z', () => {
expect(shouldHandleUndo(evt({ metaKey: true, altKey: true }))).toBe(false)
})
it('tolerates a missing key', () => {
expect(shouldHandleUndo(evt({ metaKey: true, key: undefined }))).toBe(false)
})
// The regression this module exists for: the add-task input is where focus
// normally sits, so guarding on focus alone disabled undo in the one
// situation it is actually needed.
it('handles undo when the focused input is empty', () => {
const target = { tagName: 'INPUT', value: '' }
expect(shouldHandleUndo(evt({ metaKey: true, target }))).toBe(true)
})
it('defers to the field when the focused input has text to undo', () => {
const target = { tagName: 'INPUT', value: 'half-typed task' }
expect(shouldHandleUndo(evt({ metaKey: true, target }))).toBe(false)
})
it('defers to a textarea holding text', () => {
const target = { tagName: 'TEXTAREA', value: 'notes' }
expect(shouldHandleUndo(evt({ metaKey: true, target }))).toBe(false)
})
it('defers to a contenteditable target', () => {
const target = { tagName: 'DIV', isContentEditable: true }
expect(shouldHandleUndo(evt({ metaKey: true, target }))).toBe(false)
})
it('handles undo when the target is a button', () => {
const target = { tagName: 'BUTTON' }
expect(shouldHandleUndo(evt({ metaKey: true, target }))).toBe(true)
})
it('tolerates a missing target', () => {
expect(shouldHandleUndo(evt({ metaKey: true, target: null }))).toBe(true)
})
})
+144
View File
@@ -0,0 +1,144 @@
import { isKey, toKey } from './dates.js'
const TASK_PREFIX = 'negotium-tasks-'
const THEME_KEY = 'negotium-theme'
/** Backend used in tests, and as the production fallback when localStorage is
* unavailable (Safari private mode, disabled storage, exhausted quota). */
export function createMemoryStore() {
const map = new Map()
return {
getItem: (key) => (map.has(key) ? map.get(key) : null),
setItem: (key, value) => {
map.set(key, String(value))
},
removeItem: (key) => {
map.delete(key)
},
keys: () => [...map.keys()],
}
}
function wrapWebStorage(webStorage) {
return {
getItem: (key) => webStorage.getItem(key),
setItem: (key, value) => webStorage.setItem(key, value),
removeItem: (key) => webStorage.removeItem(key),
keys: () => Object.keys(webStorage),
}
}
function resolveBackend() {
try {
const probe = '__negotium_probe__'
window.localStorage.setItem(probe, '1')
window.localStorage.removeItem(probe)
return wrapWebStorage(window.localStorage)
} catch {
return createMemoryStore()
}
}
export function createStorage(backend) {
const store = backend ?? resolveBackend()
function read(key) {
try {
return store.getItem(key)
} catch {
return null
}
}
function write(key, value) {
try {
store.setItem(key, value)
} catch {
// Quota exhausted or storage revoked mid-session. In-memory state stays
// authoritative; losing a write beats losing the app.
}
}
function drop(key) {
try {
store.removeItem(key)
} catch {
// Nothing actionable.
}
}
function allKeys() {
try {
return store.keys()
} catch {
return []
}
}
function parseTasks(raw) {
if (!raw) return []
try {
const parsed = JSON.parse(raw)
return Array.isArray(parsed) ? parsed : []
} catch {
return []
}
}
function loadTasks(dateKey) {
return parseTasks(read(TASK_PREFIX + dateKey))
}
function saveTasks(dateKey, tasks) {
write(TASK_PREFIX + dateKey, JSON.stringify(tasks))
}
function taskSuffixes() {
return allKeys()
.filter((key) => key.startsWith(TASK_PREFIX))
.map((key) => key.slice(TASK_PREFIX.length))
}
function listTaskKeys() {
return taskSuffixes().filter(isKey)
}
function removeTasks(dateKey) {
drop(TASK_PREFIX + dateKey)
}
function loadTheme() {
return read(THEME_KEY)
}
function saveTheme(mode) {
write(THEME_KEY, mode)
}
/** One-time conversion of `negotium-tasks-Sat Aug 15 2026` keys written by
* versions before the ISO format. Idempotent, and deliberately conservative:
* a suffix that will not parse is left in place rather than discarded. */
function migrateLegacyKeys() {
for (const suffix of taskSuffixes().filter((s) => !isKey(s))) {
const parsed = new Date(suffix)
if (Number.isNaN(parsed.getTime())) continue
const isoKey = toKey(parsed)
const legacy = parseTasks(read(TASK_PREFIX + suffix))
const existing = loadTasks(isoKey)
saveTasks(isoKey, [...legacy, ...existing])
drop(TASK_PREFIX + suffix)
}
}
return {
loadTasks,
saveTasks,
listTaskKeys,
removeTasks,
loadTheme,
saveTheme,
migrateLegacyKeys,
}
}
+170
View File
@@ -0,0 +1,170 @@
import { describe, it, expect } from 'vitest'
import { createStorage, createMemoryStore } from './storage.js'
const setup = (seed = {}) => {
const backend = createMemoryStore()
for (const [key, value] of Object.entries(seed)) backend.setItem(key, value)
return { backend, storage: createStorage(backend) }
}
describe('loadTasks', () => {
it('returns an empty array for a missing key', () => {
const { storage } = setup()
expect(storage.loadTasks('2026-08-15')).toEqual([])
})
it('returns stored tasks', () => {
const tasks = [{ id: 'a', text: 'buy milk', completed: false }]
const { storage } = setup({ 'negotium-tasks-2026-08-15': JSON.stringify(tasks) })
expect(storage.loadTasks('2026-08-15')).toEqual(tasks)
})
it('returns an empty array for corrupt JSON instead of throwing', () => {
const { storage } = setup({ 'negotium-tasks-2026-08-15': '{not json' })
expect(storage.loadTasks('2026-08-15')).toEqual([])
})
it('returns an empty array when the stored value is not an array', () => {
const { storage } = setup({ 'negotium-tasks-2026-08-15': '{"a":1}' })
expect(storage.loadTasks('2026-08-15')).toEqual([])
})
it('survives a throwing backend', () => {
const backend = createMemoryStore()
backend.getItem = () => {
throw new Error('storage revoked')
}
const storage = createStorage(backend)
expect(storage.loadTasks('2026-08-15')).toEqual([])
})
})
describe('saveTasks', () => {
it('round-trips through the backend', () => {
const { storage } = setup()
const tasks = [{ id: 'a', text: 'x', completed: true }]
storage.saveTasks('2026-08-15', tasks)
expect(storage.loadTasks('2026-08-15')).toEqual(tasks)
})
it('swallows a throwing backend rather than propagating', () => {
const backend = createMemoryStore()
backend.setItem = () => {
throw new Error('QuotaExceededError')
}
const storage = createStorage(backend)
expect(() => storage.saveTasks('2026-08-15', [])).not.toThrow()
})
})
describe('listTaskKeys', () => {
it('returns only date keys, with the prefix stripped', () => {
const { storage } = setup({
'negotium-tasks-2026-08-15': '[]',
'negotium-tasks-2026-08-16': '[]',
'negotium-theme': 'dark',
unrelated: 'x',
})
expect(storage.listTaskKeys().sort()).toEqual(['2026-08-15', '2026-08-16'])
})
it('excludes legacy-format keys', () => {
const { storage } = setup({
'negotium-tasks-2026-08-15': '[]',
'negotium-tasks-Sat Aug 15 2026': '[]',
})
expect(storage.listTaskKeys()).toEqual(['2026-08-15'])
})
it('returns an empty array when enumeration throws', () => {
const backend = createMemoryStore()
backend.keys = () => {
throw new Error('nope')
}
expect(createStorage(backend).listTaskKeys()).toEqual([])
})
})
describe('removeTasks', () => {
it('deletes the entry', () => {
const { storage } = setup({ 'negotium-tasks-2026-08-15': '[]' })
storage.removeTasks('2026-08-15')
expect(storage.listTaskKeys()).toEqual([])
})
})
describe('theme', () => {
it('returns null when unset', () => {
const { storage } = setup()
expect(storage.loadTheme()).toBe(null)
})
it('round-trips', () => {
const { storage } = setup()
storage.saveTheme('dark')
expect(storage.loadTheme()).toBe('dark')
})
})
describe('migrateLegacyKeys', () => {
it('rewrites a legacy key to ISO form', () => {
const tasks = [{ id: 'a', text: 'legacy', completed: false }]
const { storage } = setup({ 'negotium-tasks-Sat Aug 15 2026': JSON.stringify(tasks) })
storage.migrateLegacyKeys()
expect(storage.listTaskKeys()).toEqual(['2026-08-15'])
expect(storage.loadTasks('2026-08-15')).toEqual(tasks)
})
it('removes the legacy key once migrated', () => {
const { backend, storage } = setup({ 'negotium-tasks-Sat Aug 15 2026': '[]' })
storage.migrateLegacyKeys()
expect(backend.getItem('negotium-tasks-Sat Aug 15 2026')).toBe(null)
})
it('merges legacy before existing when the ISO key is occupied', () => {
const legacy = [{ id: 'l', text: 'legacy', completed: false }]
const current = [{ id: 'c', text: 'current', completed: false }]
const { storage } = setup({
'negotium-tasks-Sat Aug 15 2026': JSON.stringify(legacy),
'negotium-tasks-2026-08-15': JSON.stringify(current),
})
storage.migrateLegacyKeys()
expect(storage.loadTasks('2026-08-15').map((t) => t.id)).toEqual(['l', 'c'])
})
it('leaves an unparseable key untouched rather than destroying it', () => {
const { storage, backend } = setup({ 'negotium-tasks-not-a-date': '[{"id":"x"}]' })
storage.migrateLegacyKeys()
expect(backend.getItem('negotium-tasks-not-a-date')).toBe('[{"id":"x"}]')
})
it('is idempotent', () => {
const { storage } = setup({ 'negotium-tasks-Sat Aug 15 2026': '[{"id":"a"}]' })
storage.migrateLegacyKeys()
storage.migrateLegacyKeys()
expect(storage.loadTasks('2026-08-15')).toEqual([{ id: 'a' }])
})
it('migrates several legacy days independently', () => {
const { storage } = setup({
'negotium-tasks-Fri Aug 14 2026': '[{"id":"fri"}]',
'negotium-tasks-Sat Aug 15 2026': '[{"id":"sat"}]',
})
storage.migrateLegacyKeys()
expect(storage.listTaskKeys().sort()).toEqual(['2026-08-14', '2026-08-15'])
expect(storage.loadTasks('2026-08-14')).toEqual([{ id: 'fri' }])
expect(storage.loadTasks('2026-08-15')).toEqual([{ id: 'sat' }])
})
it('does nothing when there is nothing to migrate', () => {
const { storage } = setup({ 'negotium-tasks-2026-08-15': '[]' })
storage.migrateLegacyKeys()
expect(storage.listTaskKeys()).toEqual(['2026-08-15'])
})
it('leaves the theme key alone', () => {
const { backend, storage } = setup({ 'negotium-theme': 'dark' })
storage.migrateLegacyKeys()
expect(backend.getItem('negotium-theme')).toBe('dark')
})
})
+59
View File
@@ -0,0 +1,59 @@
/** crypto.randomUUID requires a secure context, and Negotium over plain HTTP
* on a LAN is a real deployment shape for this app, hence the fallback. */
function newId() {
if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') {
return crypto.randomUUID()
}
return `${Date.now()}-${Math.random().toString(36).slice(2)}`
}
export function createTask(text, now = Date.now()) {
return { id: newId(), text, completed: false, createdAt: now }
}
export function addTask(tasks, text, now = Date.now()) {
const trimmed = text.trim()
if (!trimmed) return tasks
return [...tasks, createTask(trimmed, now)]
}
export function toggleTask(tasks, id) {
return tasks.map((task) => (task.id === id ? { ...task, completed: !task.completed } : task))
}
export function deleteTask(tasks, id) {
return tasks.filter((task) => task.id !== id)
}
export function reorderTask(tasks, from, to) {
if (from === to) return tasks
if (from < 0 || from >= tasks.length) return tasks
if (to < 0 || to >= tasks.length) return tasks
const next = [...tasks]
const [moved] = next.splice(from, 1)
next.splice(to, 0, moved)
return next
}
export function clearCompleted(tasks) {
return tasks.filter((task) => !task.completed)
}
/**
* Rewrites a task's text in place, keeping its id, position and completion.
*
* Blank input is refused rather than treated as a delete. Someone who selects
* all and hits enter by accident should get their task back, not lose it, and
* an empty row would be unreadable anyway. Returning the original array when
* nothing changed also keeps a pointless write out of storage.
*/
export function renameTask(tasks, id, text) {
const trimmed = text.trim()
if (!trimmed) return tasks
const current = tasks.find((task) => task.id === id)
if (!current || current.text === trimmed) return tasks
return tasks.map((task) => (task.id === id ? { ...task, text: trimmed } : task))
}
+182
View File
@@ -0,0 +1,182 @@
import { describe, it, expect } from 'vitest'
import { addTask, toggleTask, deleteTask, reorderTask, clearCompleted, renameTask } from './tasks.js'
const task = (id, completed = false) => ({ id, text: id, completed })
describe('addTask', () => {
it('appends a task', () => {
const result = addTask([], 'buy milk')
expect(result).toHaveLength(1)
expect(result[0].text).toBe('buy milk')
expect(result[0].completed).toBe(false)
})
it('trims surrounding whitespace', () => {
expect(addTask([], ' spaced ')[0].text).toBe('spaced')
})
it('rejects whitespace-only input', () => {
const before = [task('a')]
expect(addTask(before, ' ')).toBe(before)
})
it('rejects empty input', () => {
const before = [task('a')]
expect(addTask(before, '')).toBe(before)
})
it('assigns unique ids', () => {
const one = addTask([], 'a')
const two = addTask(one, 'b')
expect(two[0].id).not.toBe(two[1].id)
})
it('assigns unique ids even when added in the same millisecond', () => {
let tasks = []
for (let i = 0; i < 50; i += 1) tasks = addTask(tasks, `task ${i}`, 1_000_000)
expect(new Set(tasks.map((t) => t.id)).size).toBe(50)
})
it('records the supplied creation time', () => {
expect(addTask([], 'x', 1_234_567)[0].createdAt).toBe(1_234_567)
})
it('does not mutate the input array', () => {
const before = [task('a')]
addTask(before, 'b')
expect(before).toHaveLength(1)
})
})
describe('toggleTask', () => {
it('flips completion', () => {
expect(toggleTask([task('a')], 'a')[0].completed).toBe(true)
})
it('flips back', () => {
expect(toggleTask([task('a', true)], 'a')[0].completed).toBe(false)
})
it('ignores an unknown id', () => {
expect(toggleTask([task('a')], 'zzz')[0].completed).toBe(false)
})
it('does not mutate the input array', () => {
const before = [task('a')]
toggleTask(before, 'a')
expect(before[0].completed).toBe(false)
})
})
describe('deleteTask', () => {
it('removes the matching task', () => {
expect(deleteTask([task('a'), task('b')], 'a').map((t) => t.id)).toEqual(['b'])
})
it('ignores an unknown id', () => {
expect(deleteTask([task('a')], 'zzz')).toHaveLength(1)
})
})
describe('reorderTask', () => {
const three = [task('a'), task('b'), task('c')]
it('moves an item later', () => {
expect(reorderTask(three, 0, 2).map((t) => t.id)).toEqual(['b', 'c', 'a'])
})
it('moves an item earlier', () => {
expect(reorderTask(three, 2, 0).map((t) => t.id)).toEqual(['c', 'a', 'b'])
})
it('moves an item into the middle', () => {
expect(reorderTask(three, 0, 1).map((t) => t.id)).toEqual(['b', 'a', 'c'])
})
it('is a no-op when indices match', () => {
expect(reorderTask(three, 1, 1)).toBe(three)
})
it('is a no-op for an out-of-range destination', () => {
expect(reorderTask(three, 0, 9)).toBe(three)
})
it('is a no-op for an out-of-range source', () => {
expect(reorderTask(three, -1, 0)).toBe(three)
})
it('does not mutate the input array', () => {
const before = [task('a'), task('b')]
reorderTask(before, 0, 1)
expect(before.map((t) => t.id)).toEqual(['a', 'b'])
})
})
describe('clearCompleted', () => {
it('removes completed tasks', () => {
const result = clearCompleted([task('a', true), task('b'), task('c', true)])
expect(result.map((t) => t.id)).toEqual(['b'])
})
it('is a no-op when nothing is completed', () => {
expect(clearCompleted([task('a')])).toHaveLength(1)
})
it('can empty the list entirely', () => {
expect(clearCompleted([task('a', true)])).toEqual([])
})
})
describe('renameTask', () => {
const task = (id, text = id, completed = false) => ({ id, text, completed, createdAt: 1 })
it('replaces the text', () => {
expect(renameTask([task('a', 'old')], 'a', 'new')[0].text).toBe('new')
})
it('trims what it is given', () => {
expect(renameTask([task('a', 'old')], 'a', ' spaced ')[0].text).toBe('spaced')
})
it('refuses to blank a task', () => {
const before = [task('a', 'keep me')]
expect(renameTask(before, 'a', '')).toBe(before)
expect(renameTask(before, 'a', ' ')).toBe(before)
})
it('keeps completion, id and creation time', () => {
const before = [{ id: 'a', text: 'old', completed: true, createdAt: 99 }]
expect(renameTask(before, 'a', 'new')[0]).toEqual({ id: 'a', text: 'new', completed: true, createdAt: 99 })
})
it('leaves the other tasks alone', () => {
const result = renameTask([task('a'), task('b'), task('c')], 'b', 'changed')
expect(result.map(t => t.text)).toEqual(['a', 'changed', 'c'])
})
it('keeps the task in place', () => {
const result = renameTask([task('a'), task('b')], 'a', 'changed')
expect(result.map(t => t.id)).toEqual(['a', 'b'])
})
it('ignores an unknown id', () => {
const before = [task('a')]
expect(renameTask(before, 'zzz', 'nope')).toBe(before)
})
it('is a no-op when the text has not changed', () => {
const before = [task('a', 'same')]
expect(renameTask(before, 'a', 'same')).toBe(before)
})
it('handles emoji and non-latin text', () => {
expect(renameTask([task('a')], 'a', '買い物 🛒')[0].text).toBe('買い物 🛒')
expect(renameTask([task('a')], 'a', 'اشتر الحليب')[0].text).toBe('اشتر الحليب')
})
it('does not mutate the input array', () => {
const before = [task('a', 'old')]
renameTask(before, 'a', 'new')
expect(before[0].text).toBe('old')
})
})
+51
View File
@@ -0,0 +1,51 @@
/** Bounded stack of reversible operations. Kept separate from tasks.js so that
* module stays purely functional while this one holds the state. */
export function createUndoStack(limit = 10) {
const entries = []
return {
push(entry) {
entries.push(entry)
if (entries.length > limit) entries.shift()
},
pop() {
return entries.length > 0 ? entries.pop() : null
},
get size() {
return entries.length
},
clear() {
entries.length = 0
},
}
}
/**
* Reverses one entry against the current task list.
*
* Indices are clamped because the list may have changed since the entry was
* recorded. A task deleted from position 5 can be restored into a list that
* has since shrunk to two items, and landing at the end beats throwing.
*
* `clearCompleted` entries must record `removed` in ascending index order, so
* re-inserting front to back puts each task back where it was.
*/
export function applyUndo(tasks, entry) {
if (!entry) return tasks
if (entry.type === 'delete') {
const next = [...tasks]
next.splice(Math.min(entry.index, next.length), 0, entry.task)
return next
}
if (entry.type === 'clearCompleted') {
const next = [...tasks]
for (const { task, index } of entry.removed) {
next.splice(Math.min(index, next.length), 0, task)
}
return next
}
return tasks
}
+100
View File
@@ -0,0 +1,100 @@
import { describe, it, expect } from 'vitest'
import { createUndoStack, applyUndo } from './undo.js'
const task = (id, completed = false) => ({ id, text: id, completed })
describe('createUndoStack', () => {
it('pops the most recent entry', () => {
const stack = createUndoStack()
stack.push({ type: 'delete', task: task('a'), index: 0 })
stack.push({ type: 'delete', task: task('b'), index: 1 })
expect(stack.pop().task.id).toBe('b')
})
it('returns null when empty', () => {
expect(createUndoStack().pop()).toBe(null)
})
it('reports its size', () => {
const stack = createUndoStack()
expect(stack.size).toBe(0)
stack.push({ type: 'delete', task: task('a'), index: 0 })
expect(stack.size).toBe(1)
})
it('is bounded, discarding the oldest entries', () => {
const stack = createUndoStack(3)
for (const id of ['a', 'b', 'c', 'd']) {
stack.push({ type: 'delete', task: task(id), index: 0 })
}
expect(stack.size).toBe(3)
expect(stack.pop().task.id).toBe('d')
expect(stack.pop().task.id).toBe('c')
expect(stack.pop().task.id).toBe('b')
expect(stack.pop()).toBe(null)
})
it('clears', () => {
const stack = createUndoStack()
stack.push({ type: 'delete', task: task('a'), index: 0 })
stack.clear()
expect(stack.size).toBe(0)
})
})
describe('applyUndo', () => {
it('restores a deleted task at its original index', () => {
const after = [task('a'), task('c')]
const entry = { type: 'delete', task: task('b'), index: 1 }
expect(applyUndo(after, entry).map((t) => t.id)).toEqual(['a', 'b', 'c'])
})
it('restores a task deleted from the front', () => {
const entry = { type: 'delete', task: task('a'), index: 0 }
expect(applyUndo([task('b')], entry).map((t) => t.id)).toEqual(['a', 'b'])
})
it('restores at the end when the list has since shrunk', () => {
const entry = { type: 'delete', task: task('b'), index: 5 }
expect(applyUndo([task('a')], entry).map((t) => t.id)).toEqual(['a', 'b'])
})
it('restores a cleared batch in original positions', () => {
const after = [task('b')]
const entry = {
type: 'clearCompleted',
removed: [
{ task: task('a', true), index: 0 },
{ task: task('c', true), index: 2 },
],
}
expect(applyUndo(after, entry).map((t) => t.id)).toEqual(['a', 'b', 'c'])
})
it('restores a batch cleared from an entirely completed list', () => {
const entry = {
type: 'clearCompleted',
removed: [
{ task: task('a', true), index: 0 },
{ task: task('b', true), index: 1 },
],
}
expect(applyUndo([], entry).map((t) => t.id)).toEqual(['a', 'b'])
})
it('is a no-op for a null entry', () => {
const tasks = [task('a')]
expect(applyUndo(tasks, null)).toBe(tasks)
})
it('is a no-op for an unrecognised entry type', () => {
const tasks = [task('a')]
expect(applyUndo(tasks, { type: 'nonsense' })).toBe(tasks)
})
it('does not mutate the input array', () => {
const before = [task('a')]
applyUndo(before, { type: 'delete', task: task('b'), index: 0 })
expect(before).toHaveLength(1)
})
})
+11 -3
View File
@@ -1,7 +1,15 @@
import { mount } from 'svelte';
import App from './App.svelte'; import App from './App.svelte';
const app = new App({ // 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'), target: document.getElementById('app'),
}); });
export default app;
+529 -111
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;
@@ -12,12 +15,13 @@
--font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; --font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
} }
.dark { html.dark {
--bg-primary: #121212; --bg-primary: #121212;
--bg-surface: #1E1E1E; --bg-surface: #1E1E1E;
--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;
@@ -86,30 +92,45 @@ body {
gap: 16px; gap: 16px;
} }
.today-btn { /* Both days are shown with the current one marked, so the control states
where you are and what the alternative is without being clicked. */
.day-switch {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 8px;
padding: 8px 16px;
background: transparent;
border: 1px solid var(--border); border: 1px solid var(--border);
border-radius: 8px; border-radius: 8px;
color: var(--text-secondary); padding: 3px;
font-size: 14px; gap: 2px;
cursor: pointer;
transition: all 200ms ease;
height: 40px; height: 40px;
transition: border-color 200ms ease;
} }
.today-btn:hover { .day-option {
padding: 0 14px;
height: 100%;
border: none;
border-radius: 6px;
background: transparent;
font-family: inherit;
font-size: 14px;
color: var(--text-secondary);
cursor: pointer;
transition: background-color 200ms ease, color 200ms ease;
white-space: nowrap;
}
.day-option:hover {
color: var(--text-primary);
}
.day-option.selected {
background-color: var(--hover); background-color: var(--hover);
border-color: var(--accent); color: var(--text-primary);
color: var(--accent); font-weight: 500;
} }
.today-btn svg { html.dark .day-option.selected {
width: 16px; background-color: var(--hover);
height: 16px;
} }
.theme-toggle { .theme-toggle {
@@ -149,7 +170,7 @@ body {
} }
.main { .main {
padding: 40px 0; padding: 40px 0 calc(40px + env(safe-area-inset-bottom));
} }
.container { .container {
@@ -175,7 +196,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 {
@@ -242,16 +263,27 @@ body {
} }
.task-list { .task-list {
display: flex;
flex-direction: column;
gap: 12px;
min-height: 400px; min-height: 400px;
position: relative; position: relative;
} }
/* A real list, so screen readers announce item counts and position. */
.task-items {
position: relative;
display: flex;
flex-direction: column;
gap: 12px;
list-style: none;
margin: 0;
padding: 0;
}
.task-item { .task-item {
display: flex; display: flex;
align-items: center; /* The row is a drag handle, so pressing and moving across it should carry
the card rather than highlight words. */
user-select: none;
align-items: flex-start;
gap: 16px; gap: 16px;
padding: 16px 20px; padding: 16px 20px;
background-color: var(--bg-surface); background-color: var(--bg-surface);
@@ -266,22 +298,36 @@ body {
cursor: grabbing; cursor: grabbing;
} }
/* The lifted card. It stays fully opaque and sits above the list. The old
ghosted 0.4 opacity read as "this card is disabled" rather than "you are
holding this card". The gap that opens beneath it is the drop indicator. */
.task-item.dragging { .task-item.dragging {
opacity: 0.4;
cursor: grabbing; cursor: grabbing;
z-index: 20;
box-shadow:
0 12px 28px rgba(0, 0, 0, 0.18),
0 4px 8px rgba(0, 0, 0, 0.1);
border-color: var(--accent);
/* Only while a drag is in flight, so an ordinary touch still scrolls. */
touch-action: none;
user-select: none;
} }
.task-item.drag-over::before { html.dark .task-item.dragging {
content: ''; box-shadow:
position: absolute; 0 12px 28px rgba(0, 0, 0, 0.5),
top: -6px; 0 4px 8px rgba(0, 0, 0, 0.35);
left: 0; }
right: 0;
height: 3px; /* The released card easing back into its slot. It keeps the raised z-index so
background-color: var(--accent); it stays above its neighbours on the way down, and supplies the transition
border-radius: 2px; that the lifted state deliberately withheld. */
box-shadow: 0 0 8px rgba(96, 122, 251, 0.4); .task-item.settling {
z-index: 10; z-index: 20;
transition:
transform 240ms cubic-bezier(0.2, 0, 0, 1),
box-shadow 240ms ease,
border-color 240ms ease;
} }
.task-item:hover { .task-item:hover {
@@ -290,14 +336,20 @@ body {
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1); box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
} }
.task-items:has(.dragging) .task-item:not(.dragging):hover {
background-color: var(--bg-surface);
box-shadow: none;
}
.task-item.completed { .task-item.completed {
background-color: var(--completed-bg); background-color: var(--completed-bg);
opacity: 0.8;
} }
.checkbox { .checkbox {
width: 24px; width: 24px;
height: 24px; height: 24px;
/* Pads the 24px box out to the 32px first-line band set by the row buttons. */
margin-block: 4px;
border: 2px solid var(--border); border: 2px solid var(--border);
border-radius: 6px; border-radius: 6px;
background: transparent; background: transparent;
@@ -335,7 +387,14 @@ body {
.task-text { .task-text {
flex: 1; flex: 1;
min-width: 0;
/* Pads the 24px line box out to the 32px first-line band the row buttons
set, so the text centres against the checkbox. */
padding-block: 4px;
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;
@@ -346,10 +405,39 @@ body {
color: var(--completed-text); color: var(--completed-text);
} }
.delete-btn { /* Sized and spaced to match .task-text exactly, so committing an edit does
not shift the row by a pixel. */
.task-edit {
flex: 1;
min-width: 0;
/* The one place text stays selectable, so it can still be copied out. */
user-select: text;
font-family: inherit;
font-size: 16px;
line-height: 1.5;
color: var(--text-primary);
background: var(--bg-primary);
border: 1px solid var(--accent);
border-radius: 6px;
padding: 0 6px;
margin: 0 -7px;
outline: none;
}
/* One group rather than three loose children, so the gaps between the actions
do not multiply across the row on a narrow screen. */
.row-actions {
display: flex;
align-items: flex-start;
gap: 2px;
flex-shrink: 0;
}
.row-btn {
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;
@@ -362,7 +450,21 @@ body {
flex-shrink: 0; flex-shrink: 0;
} }
.task-item:hover .delete-btn { .edit-btn:hover {
background-color: var(--hover);
color: var(--accent);
}
.defer-btn:hover {
background-color: var(--hover);
color: var(--accent);
}
/* focus-within matters as much as hover here: the button is opacity 0 by
default, so a keyboard user tabbing to it previously saw nothing at all:
the focus outline was drawn on an invisible element. */
.task-item:hover .row-btn,
.task-item:focus-within .row-btn {
opacity: 1; opacity: 1;
} }
@@ -372,55 +474,11 @@ body {
transform: scale(1.1); transform: scale(1.1);
} }
.delete-btn svg { .row-btn svg {
width: 16px; width: 16px;
height: 16px; height: 16px;
} }
.loading-overlay {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background-color: var(--bg-primary);
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
}
.loading {
display: flex;
flex-direction: column;
align-items: center;
gap: 16px;
}
.loading-logo {
width: 60px;
height: 60px;
color: var(--accent);
animation: pulse 1.5s ease-in-out infinite;
}
@keyframes pulse {
0%, 100% {
opacity: 1;
transform: scale(1);
}
50% {
opacity: 0.5;
transform: scale(0.95);
}
}
.loading-text {
color: var(--text-secondary);
font-size: 14px;
font-weight: 500;
}
.empty-state { .empty-state {
text-align: center; text-align: center;
padding: 60px 20px; padding: 60px 20px;
@@ -436,15 +494,59 @@ body {
justify-content: center; justify-content: center;
} }
.lottie-animation { /* Replaces a 305 KB Lottie player that existed to draw this one picture.
Same 200px box, so the empty state keeps its original proportions. */
.empty-art {
width: 200px; width: 200px;
height: 200px; height: 200px;
margin: 0 auto 24px; margin: 0 auto 24px;
filter: brightness(0) saturate(100%) invert(46%) sepia(89%) saturate(2445%) hue-rotate(224deg) brightness(101%) contrast(98%); color: var(--accent);
} }
.dark .lottie-animation { .empty-art-box {
filter: brightness(0) saturate(100%) invert(59%) sepia(51%) saturate(3127%) hue-rotate(218deg) brightness(103%) contrast(101%); animation: empty-float 4s ease-in-out infinite;
}
.empty-art-motes path {
animation: empty-mote 4s ease-in-out infinite;
}
.empty-art-motes path:nth-child(2) {
animation-delay: 0.5s;
}
.empty-art-motes path:nth-child(3) {
animation-delay: 1s;
}
@keyframes empty-float {
0%, 100% { transform: translateY(0); }
50% { transform: translateY(-4px); }
}
@keyframes empty-mote {
0%, 100% { opacity: 0.15; transform: translateY(0); }
50% { opacity: 0.6; transform: translateY(-6px); }
}
/* 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) {
.empty-art-box,
.empty-art-motes path {
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 {
@@ -452,39 +554,199 @@ body {
margin: 0; margin: 0;
} }
@media (max-width: 768px) { /* Deliberately quiet. Export and import get reached for once in a blue moon,
.header { so they sit below the list in secondary text rather than taking a slot in
padding: 16px 24px; 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) {
.row-btn {
opacity: 1;
}
}
@media (pointer: coarse) {
/* Smaller drawn box, same 44px target from the overlay below, and the
margin keeps it in the 32px first-line band. */
.row-btn {
width: 26px;
height: 26px;
margin-block: 3px;
} }
.row-actions {
gap: 0;
}
.checkbox::after,
.row-btn::after {
content: '';
position: absolute;
top: 50%;
left: 50%;
width: 44px;
height: 44px;
transform: translate(-50%, -50%);
}
/* 46 rather than 44, because the switch's own padding and border eat into
the height its buttons actually get. The theme toggle follows so the two
stay level in the header. */
.day-switch {
height: 46px;
padding: 0;
}
.day-option {
border-radius: 7px;
}
.theme-toggle {
height: 46px;
}
/* Grows the tappable area of the task text to 44px without moving the text
itself, so it stays aligned with the checkbox on the first line. */
.task-text {
padding-block: 10px;
margin-block: -6px;
}
.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) {
.header {
padding: calc(12px + env(safe-area-inset-top)) 20px 12px;
}
/* One row rather than two. Stacking cost a whole row of height on the
screen with the least of it to spare. */
.header-content { .header-content {
flex-direction: column; flex-direction: row;
gap: 16px; align-items: center;
align-items: flex-start; gap: 12px;
}
.logo-section {
gap: 10px;
min-width: 0;
} }
.header-actions { .header-actions {
width: 100%; margin-left: auto;
justify-content: space-between;
}
.container {
padding: 0 24px;
}
.content-header {
flex-direction: column;
align-items: flex-start;
gap: 8px; gap: 8px;
} }
.section-title { .main {
font-size: 28px; padding-top: 24px;
} }
.container {
padding: 0 20px;
}
/* Wraps rather than stacking unconditionally, so the date only drops to
its own line when it genuinely cannot share one. */
.content-header {
flex-direction: row;
flex-wrap: wrap;
align-items: baseline;
gap: 2px 12px;
margin-bottom: 20px;
}
.section-title {
font-size: 22px;
}
.task-input {
padding: 16px 18px;
}
/* Both row actions stay visible on touch because there is no hover to
reveal them, and at this width they were eating 44% of the row. Tighter
gaps and padding hand that back to the text. */
.task-item { .task-item {
padding: 20px; padding: 14px 16px;
min-height: 48px; gap: 10px;
} }
.task-input { .task-input {
@@ -495,7 +757,22 @@ body {
@media (max-width: 480px) { @media (max-width: 480px) {
.header { .header {
padding: 12px 16px; padding: calc(12px + env(safe-area-inset-top)) 16px 12px;
}
/* The wordmark is what stops the header fitting on one line here: it needs
422px of row and there are 343. The logo carries the identity, and the
installed app already has the name under its icon. Kept in the
accessibility tree so the page still has its h1. */
.app-title {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
} }
.container { .container {
@@ -511,16 +788,157 @@ body {
} }
} }
/* Every control, listed once. The previous version named a handful by hand
and had already gone stale: it still pointed at .today-btn, which no
longer exists, while five newer controls had no focus ring at all. */
.checkbox:focus-visible, .checkbox:focus-visible,
.delete-btn:focus-visible, .row-btn:focus-visible,
.task-edit:focus-visible,
.theme-toggle:focus-visible, .theme-toggle:focus-visible,
.today-btn:focus-visible, .day-option:focus-visible,
.clear-completed:focus-visible { .clear-completed:focus-visible,
.data-link:focus-visible,
.undo-action:focus-visible,
.carried-dismiss:focus-visible {
outline: 2px solid var(--accent); outline: 2px solid var(--accent);
outline-offset: 2px; outline-offset: 2px;
} }
/* Hidden controls cannot show a focus ring, so reveal them when tabbed to. */
.row-btn:focus-visible {
opacity: 1;
}
html { html {
scroll-behavior: smooth; scroll-behavior: smooth;
} }
/* Shown only after something is removed, and only long enough to act on.
It carries a real button because Cmd/Ctrl+Z does not exist on a phone,
so on touch this is the only way back. */
.undo-toast {
position: fixed;
left: 50%;
transform: translateX(-50%);
bottom: calc(24px + env(safe-area-inset-bottom));
z-index: 200;
display: flex;
align-items: center;
gap: 16px;
max-width: calc(100vw - 32px);
padding: 12px 12px 12px 18px;
border-radius: 12px;
background-color: var(--bg-surface);
border: 1px solid var(--border);
box-shadow:
0 12px 28px rgba(0, 0, 0, 0.16),
0 4px 8px rgba(0, 0, 0, 0.08);
font-size: 14px;
color: var(--text-primary);
}
html.dark .undo-toast {
box-shadow:
0 12px 28px rgba(0, 0, 0, 0.55),
0 4px 8px rgba(0, 0, 0, 0.4);
}
.undo-message {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.undo-action {
display: inline-flex;
align-items: center;
gap: 8px;
flex-shrink: 0;
padding: 6px 12px;
border: none;
border-radius: 8px;
background: transparent;
font-family: inherit;
font-size: 14px;
font-weight: 500;
color: var(--accent-text);
cursor: pointer;
transition: background-color 200ms ease;
}
.undo-action:hover {
background-color: var(--hover);
}
/* Teaching the shortcut is the point, so it only shows where one exists. */
.undo-key {
font-family: var(--font-family);
font-size: 12px;
color: var(--text-secondary);
border: 1px solid var(--border);
border-radius: 4px;
padding: 1px 5px;
}
@media (pointer: coarse) {
.undo-key {
display: none;
}
.undo-action {
min-height: 44px;
}
}
/* Says out loud what rollover just did. Without it, opening the app after a
few days away looks like tasks appearing from nowhere. */
.carried-notice {
display: flex;
align-items: center;
gap: 12px;
margin-bottom: 20px;
padding: 10px 10px 10px 14px;
border-radius: 8px;
background-color: var(--completed-bg);
color: var(--completed-text);
font-size: 14px;
}
.carried-notice span {
flex: 1;
min-width: 0;
}
.carried-dismiss {
flex-shrink: 0;
width: 28px;
height: 28px;
display: inline-flex;
align-items: center;
justify-content: center;
border: none;
border-radius: 6px;
background: transparent;
color: inherit;
cursor: pointer;
opacity: 0.7;
transition: opacity 200ms ease, background-color 200ms ease;
}
.carried-dismiss:hover {
opacity: 1;
background-color: var(--hover);
}
.carried-dismiss svg {
width: 16px;
height: 16px;
}
@media (pointer: coarse) {
.carried-dismiss {
width: 44px;
height: 44px;
}
}
+1 -1
View File
@@ -3,7 +3,7 @@ import { svelte } from '@sveltejs/vite-plugin-svelte'
export default defineConfig({ export default defineConfig({
plugins: [svelte()], plugins: [svelte()],
publicDir: 'assets', publicDir: 'public',
server: { server: {
port: 3000, port: 3000,
open: true open: true
+8
View File
@@ -0,0 +1,8 @@
import { defineConfig } from 'vitest/config'
export default defineConfig({
test: {
include: ['src/**/*.test.js'],
environment: 'node',
},
})