mirror of
https://github.com/aculix/negotium.git
synced 2026-09-11 07:28:17 +00:00
Compare commits
19 Commits
056f9fc562
...
v2.0.0
| Author | SHA1 | Date | |
|---|---|---|---|
| f27f1ee6d3 | |||
| 6b01e4c246 | |||
| 7e030722a9 | |||
| 6e00c0a814 | |||
| 19e8ac2f61 | |||
| 00d1e211fe | |||
| 143fcc205d | |||
| b837ecd67f | |||
| 8d6780e379 | |||
| 4c0b6de712 | |||
| cf64159322 | |||
| 756b4254b4 | |||
| 4857f1cc26 | |||
| 50509a9cc1 | |||
| f0fd6c1a65 | |||
| 5ce5b13ecd | |||
| d69c68ccef | |||
| e81e7ef8e7 | |||
| d4ddceaf73 |
@@ -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,13 +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: |
|
tags: |
|
||||||
|
type=raw,value=latest,enable={{is_default_branch}}
|
||||||
type=ref,event=branch
|
type=ref,event=branch
|
||||||
type=ref,event=pr
|
type=ref,event=pr
|
||||||
type=semver,pattern={{version}}
|
type=semver,pattern={{version}}
|
||||||
type=semver,pattern={{major}}.{{minor}}
|
type=semver,pattern={{major}}.{{minor}}
|
||||||
type=semver,pattern={{major}}
|
type=semver,pattern={{major}}
|
||||||
type=raw,value=latest,enable={{is_default_branch}}
|
|
||||||
|
|
||||||
# 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
|
||||||
@@ -91,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
-2
@@ -1,3 +1,4 @@
|
|||||||
/.DS_Store
|
.DS_Store
|
||||||
/node_modules
|
/node_modules
|
||||||
/package-lock.json
|
/dist
|
||||||
|
/.claude
|
||||||
|
|||||||
+13
-4
@@ -1,13 +1,22 @@
|
|||||||
# Build stage
|
# Build stage
|
||||||
FROM node:20-alpine AS builder
|
#
|
||||||
|
# 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 . .
|
||||||
@@ -15,7 +24,7 @@ COPY . .
|
|||||||
# Build the application
|
# Build the application
|
||||||
RUN npm run build
|
RUN npm run build
|
||||||
|
|
||||||
# Production stage
|
# Production stage — this one is built per target platform.
|
||||||
FROM nginx:alpine
|
FROM nginx:alpine
|
||||||
|
|
||||||
# Copy built files from builder stage
|
# Copy built files from builder stage
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
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.
|
||||||
|
|
||||||

|

|
||||||
|
|
||||||
## 💭 Why Negotium?
|
## 💭 Why Negotium?
|
||||||
|
|
||||||
@@ -14,11 +14,12 @@ 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** - `Cmd/Ctrl+Z` brings back a deleted task, in 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
|
- 🎯 **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
|
||||||
|
|
||||||
## 🚀 Getting Started
|
## 🚀 Getting Started
|
||||||
|
|
||||||
@@ -72,20 +73,30 @@ 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
|
- **Delete a task**: Hover over a task and click the delete icon, or press `Delete` with the task focused
|
||||||
- **Reorder tasks**: Click and drag any task to a new position
|
- **Undo a delete**: Press `Cmd/Ctrl+Z`. The task returns to where it was
|
||||||
- **Clear input**: Press Escape to clear the input field
|
- **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**: Click the date button in the header
|
||||||
- **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. 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.
|
||||||
|
|
||||||
### 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
|
||||||
@@ -94,17 +105,24 @@ 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 or clear-completed
|
||||||
|
- **Alt+↑ / Alt+↓**: Move the focused task up or down
|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
|
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 one or more lines are too long
|
Before Width: | Height: | Size: 195 KiB After Width: | Height: | Size: 195 KiB |
+17
-3
@@ -6,6 +6,22 @@
|
|||||||
<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>">
|
||||||
|
<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 +30,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>
|
||||||
|
|||||||
Generated
+1453
File diff suppressed because it is too large
Load Diff
+9
-9
@@ -1,18 +1,21 @@
|
|||||||
{
|
{
|
||||||
"name": "negotium-todo",
|
"name": "negotium-todo",
|
||||||
"version": "1.0.0",
|
"version": "2.0.0",
|
||||||
"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"
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 908 B After Width: | Height: | Size: 908 B |
+361
-197
@@ -1,238 +1,384 @@
|
|||||||
<script>
|
<script>
|
||||||
import { onMount } from 'svelte';
|
import { onMount, tick, flushSync } from 'svelte';
|
||||||
import { fly, fade } from 'svelte/transition';
|
import { fly, fade } from 'svelte/transition';
|
||||||
|
import { flip } from 'svelte/animate';
|
||||||
import { cubicOut } from 'svelte/easing';
|
import { cubicOut } from 'svelte/easing';
|
||||||
import lottie from 'lottie-web';
|
|
||||||
import './style.css';
|
import './style.css';
|
||||||
|
|
||||||
let tasks = [];
|
import { toKey, addDays, fromKey, labelFor, formatLong } from './lib/dates.js';
|
||||||
let newTask = '';
|
import { createStorage } from './lib/storage.js';
|
||||||
let darkMode = false;
|
import { rollover, msUntilNextMidnight } from './lib/rollover.js';
|
||||||
let inputElement;
|
import * as taskOps from './lib/tasks.js';
|
||||||
let isLoading = true;
|
import { createUndoStack, applyUndo } from './lib/undo.js';
|
||||||
|
import { shouldHandleUndo } from './lib/shortcuts.js';
|
||||||
|
|
||||||
|
const storage = createStorage();
|
||||||
|
const undoStack = createUndoStack();
|
||||||
|
|
||||||
|
// Storage is synchronous, so the first render can already have the real list.
|
||||||
|
// Loading in onMount instead meant one frame of the empty state on every
|
||||||
|
// launch. That was hidden behind the splash screen before, and very visible once
|
||||||
|
// that went away.
|
||||||
|
storage.migrateLegacyKeys();
|
||||||
|
const bootNow = new Date();
|
||||||
|
|
||||||
|
let tasks = $state(rollover(storage, bootNow));
|
||||||
|
let newTask = $state('');
|
||||||
|
// Seeded from the class the pre-paint script in index.html already set, so
|
||||||
|
// there is one source of truth and no post-mount correction to flash.
|
||||||
|
let darkMode = $state(document.documentElement.classList.contains('dark'));
|
||||||
let isInitialized = false;
|
let isInitialized = false;
|
||||||
let currentDate = new Date().toDateString();
|
let todayKey = $state(toKey(bootNow));
|
||||||
let selectedDate = new Date().toDateString();
|
let selectedKey = $state(toKey(bootNow));
|
||||||
let draggedItem = null;
|
let draggedId = $state(null);
|
||||||
let draggedOverIndex = null;
|
let settlingId = $state(null);
|
||||||
let currentDateDisplay = '';
|
let dragOffsetY = $state(0);
|
||||||
|
let settleTimer = null;
|
||||||
|
let midnightTimer = null;
|
||||||
|
|
||||||
|
/** Single write path, so persistence cannot drift out of step with the list.
|
||||||
|
* Loading a different day assigns `tasks` directly and deliberately skips
|
||||||
|
* this, because there is nothing new to save. */
|
||||||
|
function setTasks(next) {
|
||||||
|
tasks = next;
|
||||||
|
if (isInitialized) storage.saveTasks(selectedKey, tasks);
|
||||||
|
}
|
||||||
|
|
||||||
function addTask() {
|
function addTask() {
|
||||||
if (newTask.trim()) {
|
const next = taskOps.addTask(tasks, newTask);
|
||||||
tasks = [...tasks, {
|
if (next === tasks) return;
|
||||||
id: Date.now(),
|
setTasks(next);
|
||||||
text: newTask.trim(),
|
newTask = '';
|
||||||
completed: false,
|
|
||||||
createdAt: Date.now()
|
|
||||||
}];
|
|
||||||
newTask = '';
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function toggleTask(id) {
|
function toggleTask(id) {
|
||||||
tasks = tasks.map(task =>
|
setTasks(taskOps.toggleTask(tasks, id));
|
||||||
task.id === id ? { ...task, completed: !task.completed } : task
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function deleteTask(id) {
|
function deleteTask(id) {
|
||||||
tasks = tasks.filter(task => task.id !== id);
|
const index = tasks.findIndex(task => task.id === id);
|
||||||
|
if (index === -1) return;
|
||||||
|
undoStack.push({ type: 'delete', task: tasks[index], index });
|
||||||
|
setTasks(taskOps.deleteTask(tasks, id));
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearCompleted() {
|
||||||
|
// Built in ascending index order, which applyUndo relies on to put each
|
||||||
|
// task back where it was.
|
||||||
|
const removed = tasks
|
||||||
|
.map((task, index) => ({ task, index }))
|
||||||
|
.filter(({ task }) => task.completed);
|
||||||
|
|
||||||
|
if (removed.length === 0) return;
|
||||||
|
undoStack.push({ type: 'clearCompleted', removed });
|
||||||
|
setTasks(taskOps.clearCompleted(tasks));
|
||||||
|
}
|
||||||
|
|
||||||
|
function undo() {
|
||||||
|
const entry = undoStack.pop();
|
||||||
|
if (entry) setTasks(applyUndo(tasks, entry));
|
||||||
}
|
}
|
||||||
|
|
||||||
function toggleTheme() {
|
function toggleTheme() {
|
||||||
darkMode = !darkMode;
|
darkMode = !darkMode;
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleKeydown(event) {
|
/** Scoped to the input. Previously this also sat on window, so Enter while a
|
||||||
|
* task was focused would toggle that task *and* add whatever was in the
|
||||||
|
* input. */
|
||||||
|
function handleInputKeydown(event) {
|
||||||
if (event.key === 'Enter') addTask();
|
if (event.key === 'Enter') addTask();
|
||||||
else if (event.key === 'Escape') newTask = '';
|
else if (event.key === 'Escape') newTask = '';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function handleGlobalKeydown(event) {
|
||||||
|
if (!shouldHandleUndo(event)) return;
|
||||||
|
event.preventDefault();
|
||||||
|
undo();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Backspace no longer deletes: it is the key people press meaning "go back",
|
||||||
|
* and a task destroyed that way used to be unrecoverable. Delete still does,
|
||||||
|
* and Cmd/Ctrl+Z now reverses it. */
|
||||||
function handleTaskKeydown(event, taskId) {
|
function handleTaskKeydown(event, taskId) {
|
||||||
if (event.key === ' ' || event.key === 'Enter') {
|
if (event.key === 'Delete') {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
toggleTask(taskId);
|
|
||||||
} else if (event.key === 'Delete' || event.key === 'Backspace') {
|
|
||||||
deleteTask(taskId);
|
deleteTask(taskId);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reordering must not be pointer-only. The keyed each block moves the
|
||||||
|
// existing DOM node, so focus travels with the row.
|
||||||
|
if (event.altKey && (event.key === 'ArrowUp' || event.key === 'ArrowDown')) {
|
||||||
|
event.preventDefault();
|
||||||
|
moveTask(taskId, event.key === 'ArrowUp' ? -1 : 1);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleDragStart(event, index) {
|
// Reordering runs on Pointer Events rather than HTML5 drag-and-drop, which
|
||||||
draggedItem = index;
|
// never fired on touch at all, so a documented feature was desktop-only.
|
||||||
event.dataTransfer.effectAllowed = 'move';
|
//
|
||||||
event.dataTransfer.setData('text/html', event.target);
|
// Touch and mouse need different entry conditions. A mouse drag begins once
|
||||||
}
|
// the pointer has moved past a small threshold. A touch drag cannot, because
|
||||||
|
// a vertical swipe on a list is far more likely to mean "scroll"; it begins
|
||||||
|
// on a long press instead, and any movement before that cancels the intent
|
||||||
|
// and lets the page scroll normally.
|
||||||
|
const DRAG_THRESHOLD_PX = 8;
|
||||||
|
const LONG_PRESS_MS = 400;
|
||||||
|
const SETTLE_MS = 240;
|
||||||
|
|
||||||
function handleDragOver(event, index) {
|
let drag = null;
|
||||||
|
|
||||||
|
function blockTouchScroll(event) {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
draggedOverIndex = index;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleDragEnd(event) {
|
function beginDrag() {
|
||||||
event.preventDefault();
|
if (!drag || drag.active) return;
|
||||||
|
drag.active = true;
|
||||||
|
draggedId = drag.id;
|
||||||
|
dragOffsetY = 0;
|
||||||
|
|
||||||
if (draggedItem !== null && draggedOverIndex !== null && draggedItem !== draggedOverIndex) {
|
// Layout position of the row, which offsetTop reports free of any
|
||||||
const newTasks = [...tasks];
|
// transform. It is the fixed reference the pointer offset is measured
|
||||||
const [movedTask] = newTasks.splice(draggedItem, 1);
|
// against, and it stays correct as the row changes slots mid-drag.
|
||||||
newTasks.splice(draggedOverIndex, 0, movedTask);
|
drag.originTop = drag.row.offsetTop;
|
||||||
tasks = newTasks;
|
|
||||||
|
// A settling card from a previous drop must not keep its transition, or
|
||||||
|
// it would fight the new gesture.
|
||||||
|
clearTimeout(settleTimer);
|
||||||
|
settlingId = null;
|
||||||
|
|
||||||
|
try {
|
||||||
|
drag.row.setPointerCapture(drag.pointerId);
|
||||||
|
} catch {
|
||||||
|
// Capture is an optimisation; the gesture still works without it.
|
||||||
}
|
}
|
||||||
|
|
||||||
draggedItem = null;
|
if (drag.pointerType !== 'mouse') {
|
||||||
draggedOverIndex = null;
|
// touch-action alone cannot stop a gesture already in flight.
|
||||||
}
|
document.addEventListener('touchmove', blockTouchScroll, { passive: false });
|
||||||
|
|
||||||
function handleDragLeave() {
|
|
||||||
draggedOverIndex = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
$: {
|
|
||||||
currentDateDisplay = (() => {
|
|
||||||
const date = new Date(selectedDate);
|
|
||||||
return date.toLocaleDateString('en-US', {
|
|
||||||
weekday: 'long',
|
|
||||||
year: 'numeric',
|
|
||||||
month: 'long',
|
|
||||||
day: 'numeric'
|
|
||||||
});
|
|
||||||
})();
|
|
||||||
}
|
|
||||||
|
|
||||||
function getDateKey(dateString) {
|
|
||||||
return `negotium-tasks-${dateString}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function loadTasksForDate(dateString) {
|
|
||||||
const savedTasks = localStorage.getItem(getDateKey(dateString));
|
|
||||||
if (savedTasks) {
|
|
||||||
tasks = JSON.parse(savedTasks);
|
|
||||||
} else {
|
|
||||||
tasks = [];
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function saveTasksForDate(dateString) {
|
function endDrag() {
|
||||||
localStorage.setItem(getDateKey(dateString), JSON.stringify(tasks));
|
if (!drag) return;
|
||||||
|
|
||||||
|
clearTimeout(drag.timer);
|
||||||
|
document.removeEventListener('touchmove', blockTouchScroll);
|
||||||
|
try {
|
||||||
|
drag.row.releasePointerCapture(drag.pointerId);
|
||||||
|
} catch {
|
||||||
|
// Already released, or never captured.
|
||||||
|
}
|
||||||
|
|
||||||
|
drag = null;
|
||||||
|
draggedId = null;
|
||||||
|
dragOffsetY = 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
function checkAndMigrateTasks() {
|
/**
|
||||||
const today = new Date().toDateString();
|
* Inline styling for the one row the pointer is carrying.
|
||||||
const yesterday = new Date();
|
*
|
||||||
yesterday.setDate(yesterday.getDate() - 1);
|
* Only the lifted card is styled here. Everything else is moved by
|
||||||
const yesterdayString = yesterday.toDateString();
|
* animate:flip, so one mechanism owns `transform` per element. When both
|
||||||
|
* did, flip measured a "before" rect that already had a manual offset in
|
||||||
|
* it, computed a bogus delta, and slid the whole list on drop.
|
||||||
|
*
|
||||||
|
* The lifted card takes no transition, so it stays welded to the pointer.
|
||||||
|
* On release it keeps its transform but gains one via the settling class,
|
||||||
|
* which eases it into its slot instead of snapping.
|
||||||
|
*/
|
||||||
|
function rowStyle(taskId) {
|
||||||
|
if (taskId !== draggedId) return '';
|
||||||
|
return `transform: translateY(${dragOffsetY}px) scale(1.02) rotate(-0.4deg); transition: none;`;
|
||||||
|
}
|
||||||
|
|
||||||
const yesterdayTasks = localStorage.getItem(getDateKey(yesterdayString));
|
function targetIndexFor(clientY) {
|
||||||
if (yesterdayTasks && currentDate !== today) {
|
const list = drag.row.parentElement;
|
||||||
const tasks = JSON.parse(yesterdayTasks);
|
// offsetTop and offsetHeight are layout values, unaffected by the
|
||||||
const todayTasks = localStorage.getItem(getDateKey(today));
|
// transforms in play, so the target cannot feed back into itself.
|
||||||
|
const y = clientY - list.getBoundingClientRect().top;
|
||||||
|
const children = [...list.children];
|
||||||
|
|
||||||
if (todayTasks) {
|
for (let i = 0; i < children.length; i += 1) {
|
||||||
const existingTodayTasks = JSON.parse(todayTasks);
|
const child = children[i];
|
||||||
localStorage.setItem(getDateKey(today), JSON.stringify([...existingTodayTasks, ...tasks]));
|
if (y < child.offsetTop + child.offsetHeight / 2) return i;
|
||||||
} else {
|
}
|
||||||
localStorage.setItem(getDateKey(today), yesterdayTasks);
|
return children.length - 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
function handlePointerDown(event, index) {
|
||||||
|
if (event.pointerType === 'mouse' && event.button !== 0) return;
|
||||||
|
// Let the checkbox and delete button have their clicks.
|
||||||
|
if (event.target.closest('button')) return;
|
||||||
|
|
||||||
|
drag = {
|
||||||
|
id: tasks[index].id,
|
||||||
|
originIndex: index,
|
||||||
|
pointerId: event.pointerId,
|
||||||
|
pointerType: event.pointerType,
|
||||||
|
startY: event.clientY,
|
||||||
|
row: event.currentTarget,
|
||||||
|
active: false,
|
||||||
|
timer: null,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (event.pointerType !== 'mouse') {
|
||||||
|
drag.timer = setTimeout(beginDrag, LONG_PRESS_MS);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handlePointerMove(event) {
|
||||||
|
if (!drag) return;
|
||||||
|
|
||||||
|
if (!drag.active) {
|
||||||
|
const moved = Math.abs(event.clientY - drag.startY);
|
||||||
|
if (drag.pointerType === 'mouse') {
|
||||||
|
if (moved > DRAG_THRESHOLD_PX) beginDrag();
|
||||||
|
} else if (moved > DRAG_THRESHOLD_PX) {
|
||||||
|
// Moved before the long press landed: this is a scroll, not a drag.
|
||||||
|
endDrag();
|
||||||
}
|
}
|
||||||
|
if (!drag?.active) return;
|
||||||
localStorage.removeItem(getDateKey(yesterdayString));
|
|
||||||
currentDate = today;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Reorder as the pointer crosses each boundary rather than waiting for the
|
||||||
|
// drop. animate:flip then eases the displaced card across, one swap at a
|
||||||
|
// time. By release the list is already in its final order, so letting
|
||||||
|
// go changes nothing but the lifted card settling into place.
|
||||||
|
const from = tasks.findIndex(task => task.id === drag.id);
|
||||||
|
const to = targetIndexFor(event.clientY);
|
||||||
|
|
||||||
|
if (from !== -1 && to !== from) {
|
||||||
|
tasks = taskOps.reorderTask(tasks, from, to);
|
||||||
|
// Apply now, so the row's new offsetTop is readable on the next line.
|
||||||
|
flushSync();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Measured against layout, so the card stays under the pointer even though
|
||||||
|
// the slot beneath it just changed.
|
||||||
|
dragOffsetY = (event.clientY - drag.startY) - (drag.row.offsetTop - drag.originTop);
|
||||||
|
}
|
||||||
|
|
||||||
|
function handlePointerUp() {
|
||||||
|
if (!drag) return;
|
||||||
|
|
||||||
|
const wasActive = drag.active;
|
||||||
|
const landedAt = tasks.findIndex(task => task.id === drag.id);
|
||||||
|
const settling = drag.id;
|
||||||
|
|
||||||
|
// The order is already correct, applied swap by swap during the
|
||||||
|
// drag, so this only writes it through to storage.
|
||||||
|
if (wasActive && landedAt !== drag.originIndex) setTasks(tasks);
|
||||||
|
|
||||||
|
endDrag();
|
||||||
|
|
||||||
|
if (!wasActive) return;
|
||||||
|
|
||||||
|
// Clearing the inline transform while the settling class supplies a
|
||||||
|
// transition eases the card into its slot. Nothing else on the list moves,
|
||||||
|
// because nothing else changed.
|
||||||
|
settlingId = settling;
|
||||||
|
clearTimeout(settleTimer);
|
||||||
|
settleTimer = setTimeout(() => { settlingId = null; }, SETTLE_MS);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function moveTask(taskId, offset) {
|
||||||
|
const from = tasks.findIndex(task => task.id === taskId);
|
||||||
|
const to = from + offset;
|
||||||
|
if (from === -1 || to < 0 || to >= tasks.length) return;
|
||||||
|
|
||||||
|
setTasks(taskOps.reorderTask(tasks, from, to));
|
||||||
|
|
||||||
|
// Reconciling the keyed list drops focus, which would make Alt+Arrow a
|
||||||
|
// one-shot: the second press would land on nothing. Put it back on the
|
||||||
|
// task that moved so the key can be held down.
|
||||||
|
await tick();
|
||||||
|
const row = [...document.querySelectorAll('.task-item')]
|
||||||
|
.find(el => el.dataset.taskId === String(taskId));
|
||||||
|
row?.querySelector('.checkbox')?.focus();
|
||||||
|
}
|
||||||
|
|
||||||
|
function tomorrowKey() {
|
||||||
|
return toKey(addDays(fromKey(todayKey), 1));
|
||||||
}
|
}
|
||||||
|
|
||||||
function switchDate() {
|
function switchDate() {
|
||||||
const today = new Date();
|
selectedKey = selectedKey === todayKey ? tomorrowKey() : todayKey;
|
||||||
const tomorrow = new Date(today);
|
tasks = storage.loadTasks(selectedKey);
|
||||||
tomorrow.setDate(tomorrow.getDate() + 1);
|
|
||||||
|
|
||||||
selectedDate = selectedDate === today.toDateString()
|
|
||||||
? tomorrow.toDateString()
|
|
||||||
: today.toDateString();
|
|
||||||
|
|
||||||
loadTasksForDate(selectedDate);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
$: buttonText = (() => {
|
/**
|
||||||
const today = new Date();
|
* Re-evaluates the day boundary and reloads the visible list. Safe to call
|
||||||
const tomorrow = new Date(today);
|
* repeatedly, since rollover is idempotent within a day.
|
||||||
tomorrow.setDate(tomorrow.getDate() + 1);
|
*
|
||||||
|
* If the day changed while the user was looking at Today, they stay on the
|
||||||
|
* new Today. If they were looking at Tomorrow, that key has become Today and
|
||||||
|
* they follow it there, which preserves the existing mental model.
|
||||||
|
*/
|
||||||
|
function runRollover() {
|
||||||
|
const now = new Date();
|
||||||
|
const wasViewingToday = selectedKey === todayKey;
|
||||||
|
|
||||||
if (selectedDate === today.toDateString()) return 'Today';
|
todayKey = toKey(now);
|
||||||
if (selectedDate === tomorrow.toDateString()) return 'Tomorrow';
|
const todayTasks = rollover(storage, now);
|
||||||
return selectedDate.split(' ').slice(0, 3).join(' ');
|
|
||||||
})();
|
|
||||||
|
|
||||||
$: remainingTasks = tasks.filter(task => !task.completed).length;
|
if (wasViewingToday) {
|
||||||
$: completedTasks = tasks.filter(task => task.completed).length;
|
selectedKey = todayKey;
|
||||||
|
tasks = todayTasks;
|
||||||
$: if (tasks && isInitialized) {
|
} else {
|
||||||
saveTasksForDate(selectedDate);
|
tasks = storage.loadTasks(selectedKey);
|
||||||
}
|
|
||||||
|
|
||||||
$: if (darkMode !== undefined && isInitialized) {
|
|
||||||
localStorage.setItem('negotium-theme', darkMode ? 'dark' : 'light');
|
|
||||||
}
|
|
||||||
|
|
||||||
function initLottie(node) {
|
|
||||||
let instance = null;
|
|
||||||
|
|
||||||
async function loadAnimation() {
|
|
||||||
try {
|
|
||||||
const response = await fetch('/lottie_empty_state.json');
|
|
||||||
const animationData = await response.json();
|
|
||||||
|
|
||||||
instance = lottie.loadAnimation({
|
|
||||||
container: node,
|
|
||||||
renderer: 'svg',
|
|
||||||
loop: true,
|
|
||||||
autoplay: true,
|
|
||||||
animationData: animationData
|
|
||||||
});
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Failed to load Lottie animation:', error);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
loadAnimation();
|
|
||||||
|
|
||||||
return {
|
|
||||||
destroy() {
|
|
||||||
if (instance) {
|
|
||||||
instance.destroy();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function scheduleMidnight() {
|
||||||
|
clearTimeout(midnightTimer);
|
||||||
|
midnightTimer = setTimeout(() => {
|
||||||
|
runRollover();
|
||||||
|
scheduleMidnight();
|
||||||
|
}, msUntilNextMidnight(new Date()));
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleVisibility() {
|
||||||
|
if (document.visibilityState === 'visible') runRollover();
|
||||||
|
}
|
||||||
|
|
||||||
|
const currentDateDisplay = $derived(formatLong(selectedKey));
|
||||||
|
const buttonText = $derived(labelFor(selectedKey, todayKey));
|
||||||
|
|
||||||
|
const remainingTasks = $derived(tasks.filter(task => !task.completed).length);
|
||||||
|
const completedTasks = $derived(tasks.filter(task => task.completed).length);
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
document.documentElement.classList.toggle('dark', darkMode);
|
||||||
|
if (!isInitialized) return;
|
||||||
|
storage.saveTheme(darkMode ? 'dark' : 'light');
|
||||||
|
});
|
||||||
|
|
||||||
onMount(() => {
|
onMount(() => {
|
||||||
checkAndMigrateTasks();
|
// State is already loaded above; this only opens the write path and lets
|
||||||
loadTasksForDate(selectedDate);
|
// the theme effect run once without persisting on a plain page load.
|
||||||
|
isInitialized = true;
|
||||||
|
|
||||||
const savedTheme = localStorage.getItem('negotium-theme');
|
// Four triggers, because no single one is sufficient. The timer covers a
|
||||||
darkMode = savedTheme ? savedTheme === 'dark' : window.matchMedia('(prefers-color-scheme: dark)').matches;
|
// pinned tab crossing midnight unattended; visibility and focus cover
|
||||||
|
// machine sleep, where timers do not reliably fire.
|
||||||
|
scheduleMidnight();
|
||||||
|
document.addEventListener('visibilitychange', handleVisibility);
|
||||||
|
window.addEventListener('focus', runRollover);
|
||||||
|
|
||||||
setTimeout(() => {
|
return () => {
|
||||||
isLoading = false;
|
clearTimeout(midnightTimer);
|
||||||
isInitialized = true;
|
document.removeEventListener('visibilitychange', handleVisibility);
|
||||||
}, 500);
|
window.removeEventListener('focus', runRollover);
|
||||||
|
};
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<svelte:window on:keydown={handleKeydown} />
|
<svelte:window onkeydown={handleGlobalKeydown} />
|
||||||
|
|
||||||
<div class="app" class:dark={darkMode}>
|
<div class="app">
|
||||||
{#if isLoading}
|
|
||||||
<div class="loading-overlay" transition:fade={{ duration: 300 }}>
|
|
||||||
<div class="loading">
|
|
||||||
<svg class="loading-logo" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
|
||||||
<path d="M12.37 8.87988H17.62" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
|
|
||||||
<path d="M6.38 8.87988L7.13 9.62988L9.38 7.37988" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
|
|
||||||
<path d="M12.37 15.8799H17.62" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
|
|
||||||
<path d="M6.38 15.8799L7.13 16.6299L9.38 14.3799" stroke="currentColor" 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="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
|
|
||||||
</svg>
|
|
||||||
<div class="loading-text">Loading Negotium...</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{:else}
|
|
||||||
<header class="header">
|
<header class="header">
|
||||||
<div class="header-content">
|
<div class="header-content">
|
||||||
<div class="logo-section">
|
<div class="logo-section">
|
||||||
@@ -247,7 +393,7 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="header-actions">
|
<div class="header-actions">
|
||||||
<button class="today-btn" aria-label="Switch between Today and Tomorrow" on:click={switchDate}>
|
<button class="today-btn" aria-label="Switch between Today and Tomorrow" onclick={switchDate}>
|
||||||
<svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
<svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
<rect x="3" y="4" width="18" height="18" rx="2" ry="2" stroke="currentColor" stroke-width="2"/>
|
<rect x="3" y="4" width="18" height="18" rx="2" ry="2" stroke="currentColor" stroke-width="2"/>
|
||||||
<line x1="16" y1="2" x2="16" y2="6" stroke="currentColor" stroke-width="2"/>
|
<line x1="16" y1="2" x2="16" y2="6" stroke="currentColor" stroke-width="2"/>
|
||||||
@@ -259,7 +405,7 @@
|
|||||||
|
|
||||||
<button
|
<button
|
||||||
class="theme-toggle"
|
class="theme-toggle"
|
||||||
on:click={toggleTheme}
|
onclick={toggleTheme}
|
||||||
aria-label={darkMode ? 'Switch to light mode' : 'Switch to dark mode'}
|
aria-label={darkMode ? 'Switch to light mode' : 'Switch to dark mode'}
|
||||||
>
|
>
|
||||||
<svg class="theme-icon" class:rotated={darkMode} viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
<svg class="theme-icon" class:rotated={darkMode} viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
@@ -291,12 +437,11 @@
|
|||||||
|
|
||||||
<div class="task-input-container">
|
<div class="task-input-container">
|
||||||
<input
|
<input
|
||||||
bind:this={inputElement}
|
|
||||||
bind:value={newTask}
|
bind:value={newTask}
|
||||||
type="text"
|
type="text"
|
||||||
placeholder="+ Add a task"
|
placeholder="+ Add a task"
|
||||||
class="task-input"
|
class="task-input"
|
||||||
on:keydown={handleKeydown}
|
onkeydown={handleInputKeydown}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -305,43 +450,62 @@
|
|||||||
{remainingTasks} {remainingTasks === 1 ? 'task' : 'tasks'} remaining
|
{remainingTasks} {remainingTasks === 1 ? 'task' : 'tasks'} remaining
|
||||||
</span>
|
</span>
|
||||||
{#if completedTasks > 0}
|
{#if completedTasks > 0}
|
||||||
<button class="clear-completed" on:click={() => tasks = tasks.filter(task => !task.completed)}>
|
<button class="clear-completed" onclick={clearCompleted}>
|
||||||
Clear completed
|
Clear completed
|
||||||
</button>
|
</button>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="task-list">
|
<div class="task-list">
|
||||||
{#key selectedDate}
|
{#key selectedKey}
|
||||||
{#if tasks.length === 0}
|
{#if tasks.length === 0}
|
||||||
<div class="empty-state" transition:fade={{ duration: 200 }}>
|
<div class="empty-state" transition:fade={{ duration: 200 }}>
|
||||||
<div class="lottie-animation" use:initLottie></div>
|
<svg class="empty-art" viewBox="0 0 120 120" fill="none" aria-hidden="true" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<g class="empty-art-motes" stroke="currentColor" stroke-width="3" stroke-linecap="round">
|
||||||
|
<path d="M40 34 L40 34" />
|
||||||
|
<path d="M60 26 L60 26" />
|
||||||
|
<path d="M80 34 L80 34" />
|
||||||
|
</g>
|
||||||
|
<g class="empty-art-box" stroke="currentColor" stroke-width="3" stroke-linecap="round" stroke-linejoin="round">
|
||||||
|
<path d="M30 58 L30 92 L90 92 L90 58" />
|
||||||
|
<path d="M24 58 L96 58" />
|
||||||
|
<path d="M30 58 L18 45" />
|
||||||
|
<path d="M90 58 L102 45" />
|
||||||
|
<path d="M48 74 L72 74" opacity="0.35" />
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
<p>No tasks yet. Add one above to get started!</p>
|
<p>No tasks yet. Add one above to get started!</p>
|
||||||
</div>
|
</div>
|
||||||
{:else}
|
{:else}
|
||||||
|
<ul class="task-items">
|
||||||
{#each tasks as task, index (task.id)}
|
{#each tasks as task, index (task.id)}
|
||||||
<div
|
<!-- The row is not itself focusable. Its handlers act on events
|
||||||
|
bubbling up from the buttons inside it, and every action they
|
||||||
|
provide is reachable from the keyboard: Delete removes a task,
|
||||||
|
Alt+Arrow reorders one. -->
|
||||||
|
<!-- svelte-ignore a11y_no_noninteractive_element_interactions -->
|
||||||
|
<li
|
||||||
class="task-item"
|
class="task-item"
|
||||||
|
data-task-id={task.id}
|
||||||
class:completed={task.completed}
|
class:completed={task.completed}
|
||||||
class:dragging={draggedItem === index}
|
class:dragging={task.id === draggedId}
|
||||||
class:drag-over={draggedOverIndex === index}
|
class:settling={task.id === settlingId}
|
||||||
draggable="true"
|
style={rowStyle(task.id)}
|
||||||
|
animate:flip={{ duration: task.id === draggedId ? 0 : 240, easing: cubicOut }}
|
||||||
in:fly={{ y: -10, duration: 300, delay: index * 30, easing: cubicOut }}
|
in:fly={{ y: -10, duration: 300, delay: index * 30, easing: cubicOut }}
|
||||||
out:fly={{ x: 30, opacity: 0, duration: 250, delay: index * 20, easing: cubicOut }}
|
out:fly={{ x: 30, opacity: 0, duration: 250, delay: index * 20, easing: cubicOut }}
|
||||||
on:dragstart={(e) => handleDragStart(e, index)}
|
onpointerdown={(e) => handlePointerDown(e, index)}
|
||||||
on:dragover={(e) => handleDragOver(e, index)}
|
onpointermove={handlePointerMove}
|
||||||
on:dragend={handleDragEnd}
|
onpointerup={handlePointerUp}
|
||||||
on:dragleave={handleDragLeave}
|
onpointercancel={endDrag}
|
||||||
on:keydown={(e) => handleTaskKeydown(e, task.id)}
|
onkeydown={(e) => handleTaskKeydown(e, task.id)}
|
||||||
tabindex="0"
|
|
||||||
role="button"
|
|
||||||
aria-label={task.completed ? `Completed: ${task.text}` : `Incomplete: ${task.text}`}
|
|
||||||
>
|
>
|
||||||
<button
|
<button
|
||||||
class="checkbox"
|
class="checkbox"
|
||||||
class:checked={task.completed}
|
class:checked={task.completed}
|
||||||
on:click={() => toggleTask(task.id)}
|
onclick={() => toggleTask(task.id)}
|
||||||
aria-label={task.completed ? 'Mark as incomplete' : 'Mark as complete'}
|
aria-pressed={task.completed}
|
||||||
|
aria-label={task.text}
|
||||||
>
|
>
|
||||||
{#if task.completed}
|
{#if task.completed}
|
||||||
<svg class="checkmark" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
<svg class="checkmark" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
@@ -354,8 +518,8 @@
|
|||||||
|
|
||||||
<button
|
<button
|
||||||
class="delete-btn"
|
class="delete-btn"
|
||||||
on:click={() => deleteTask(task.id)}
|
onclick={() => deleteTask(task.id)}
|
||||||
aria-label="Delete task"
|
aria-label="Delete {task.text}"
|
||||||
>
|
>
|
||||||
<svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
<svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
<polyline points="3,6 5,6 21,6" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
<polyline points="3,6 5,6 21,6" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
@@ -364,12 +528,12 @@
|
|||||||
<line x1="14" y1="11" x2="14" y2="17" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
<line x1="14" y1="11" x2="14" y2="17" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
</svg>
|
</svg>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</li>
|
||||||
{/each}
|
{/each}
|
||||||
|
</ul>
|
||||||
{/if}
|
{/if}
|
||||||
{/key}
|
{/key}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</main>
|
</main>
|
||||||
{/if}
|
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -0,0 +1,44 @@
|
|||||||
|
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 labelFor(dateKey, todayKey) {
|
||||||
|
if (dateKey === todayKey) return 'Today'
|
||||||
|
if (dateKey === toKey(addDays(fromKey(todayKey), 1))) return 'Tomorrow'
|
||||||
|
return fromKey(dateKey).toLocaleDateString('en-US', { month: 'short', day: 'numeric' })
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatLong(dateKey) {
|
||||||
|
return fromKey(dateKey).toLocaleDateString('en-US', {
|
||||||
|
weekday: 'long',
|
||||||
|
year: 'numeric',
|
||||||
|
month: 'long',
|
||||||
|
day: 'numeric',
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -0,0 +1,89 @@
|
|||||||
|
import { describe, it, expect } from 'vitest'
|
||||||
|
import { toKey, fromKey, addDays, isKey, labelFor, 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('labelFor', () => {
|
||||||
|
it('labels today', () => {
|
||||||
|
expect(labelFor('2026-08-15', '2026-08-15')).toBe('Today')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('labels tomorrow', () => {
|
||||||
|
expect(labelFor('2026-08-16', '2026-08-15')).toBe('Tomorrow')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('labels tomorrow across a month boundary', () => {
|
||||||
|
expect(labelFor('2026-09-01', '2026-08-31')).toBe('Tomorrow')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('labels tomorrow across a year boundary', () => {
|
||||||
|
expect(labelFor('2027-01-01', '2026-12-31')).toBe('Tomorrow')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('falls back to a short date for other days', () => {
|
||||||
|
expect(labelFor('2026-08-20', '2026-08-15')).toBe('Aug 20')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('formatLong', () => {
|
||||||
|
it('renders the long-form date used in the header', () => {
|
||||||
|
expect(formatLong('2026-08-15')).toBe('Saturday, August 15, 2026')
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -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())
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -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,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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')
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
/** 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)
|
||||||
|
}
|
||||||
@@ -0,0 +1,128 @@
|
|||||||
|
import { describe, it, expect } from 'vitest'
|
||||||
|
import { addTask, toggleTask, deleteTask, reorderTask, clearCompleted } 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([])
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
})
|
||||||
|
})
|
||||||
+2
-3
@@ -1,7 +1,6 @@
|
|||||||
|
import { mount } from 'svelte';
|
||||||
import App from './App.svelte';
|
import App from './App.svelte';
|
||||||
|
|
||||||
const app = new App({
|
export default mount(App, {
|
||||||
target: document.getElementById('app'),
|
target: document.getElementById('app'),
|
||||||
});
|
});
|
||||||
|
|
||||||
export default app;
|
|
||||||
|
|||||||
+85
-61
@@ -12,7 +12,7 @@
|
|||||||
--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;
|
||||||
@@ -242,11 +242,19 @@ body {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.task-list {
|
.task-list {
|
||||||
|
min-height: 400px;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* A real list, so screen readers announce item counts and position. */
|
||||||
|
.task-items {
|
||||||
|
position: relative;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 12px;
|
gap: 12px;
|
||||||
min-height: 400px;
|
list-style: none;
|
||||||
position: relative;
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.task-item {
|
.task-item {
|
||||||
@@ -266,22 +274,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,6 +312,11 @@ 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;
|
opacity: 0.8;
|
||||||
@@ -362,7 +389,11 @@ body {
|
|||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.task-item:hover .delete-btn {
|
/* 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 .delete-btn,
|
||||||
|
.task-item:focus-within .delete-btn {
|
||||||
opacity: 1;
|
opacity: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -377,50 +408,6 @@ body {
|
|||||||
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,10 +423,47 @@ 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;
|
||||||
|
color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-art-box {
|
||||||
|
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. */
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
.empty-art-box,
|
||||||
|
.empty-art-motes path {
|
||||||
|
animation: none;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.empty-state p {
|
.empty-state p {
|
||||||
|
|||||||
+1
-1
@@ -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
|
||||||
|
|||||||
@@ -0,0 +1,8 @@
|
|||||||
|
import { defineConfig } from 'vitest/config'
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
test: {
|
||||||
|
include: ['src/**/*.test.js'],
|
||||||
|
environment: 'node',
|
||||||
|
},
|
||||||
|
})
|
||||||
Reference in New Issue
Block a user