fix: say what carry-over did, and stop the day control being a riddle

Two pieces of copy that were making the app harder to read than it needs
to be.

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

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

Also drops labelFor from dates.js along with its tests. It existed to
label that one button and nothing uses it now.
This commit is contained in:
Aculix Technologies
2026-08-16 03:36:16 +05:30
parent d97550feb8
commit 1198158abf
5 changed files with 140 additions and 59 deletions
+2 -2
View File
@@ -98,10 +98,10 @@ The date, storage, rollover, task and undo logic lives in `src/lib/` as plain mo
- **Clear input**: Press Escape while the input is focused - **Clear input**: Press Escape while the input is focused
### Date Management ### Date Management
- **Switch between Today and Tomorrow**: Click the date button in the header - **Switch between Today and Tomorrow**: Pick either one in the header. The current day is the highlighted one
- **Plan ahead**: Add tasks to Tomorrow's list before you need them - **Plan ahead**: Add tasks to Tomorrow's list before you need them
- **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. - **Carry-over**: When a new day begins, whatever you didn't finish moves into Today, and the app tells you how many tasks it brought forward so they don't look like they appeared from nowhere. Completed tasks are cleared away with the day they belonged to. It works across gaps too. If you don't open Negotium for a week, everything still outstanding is waiting for you.
- **While it's open**: The app notices the day change on its own, so a tab left open overnight rolls over without a reload. - **While it's open**: The app notices the day change on its own, so a tab left open overnight rolls over without a reload.
### Installing It ### Installing It
+55 -13
View File
@@ -5,7 +5,7 @@
import { cubicOut } from 'svelte/easing'; import { cubicOut } from 'svelte/easing';
import './style.css'; import './style.css';
import { toKey, addDays, fromKey, labelFor, formatLong } from './lib/dates.js'; import { toKey, addDays, fromKey, formatLong } from './lib/dates.js';
import { createStorage } from './lib/storage.js'; import { createStorage } from './lib/storage.js';
import { rollover, msUntilNextMidnight } from './lib/rollover.js'; import { rollover, msUntilNextMidnight } from './lib/rollover.js';
import * as taskOps from './lib/tasks.js'; import * as taskOps from './lib/tasks.js';
@@ -24,7 +24,19 @@
storage.migrateLegacyKeys(); storage.migrateLegacyKeys();
const bootNow = new Date(); const bootNow = new Date();
const bootTodayKey = toKey(bootNow);
const beforeRollover = storage.loadTasks(bootTodayKey).length;
let tasks = $state(rollover(storage, bootNow)); let tasks = $state(rollover(storage, bootNow));
/** How many unfinished tasks were pulled forward from earlier days on this
* load. The app's cleverest behaviour used to happen in complete silence,
* so returning after a weekend looked like a bug rather than a feature. */
let carriedOver = $state(Math.max(0, tasks.length - beforeRollover));
function dismissCarriedOver() {
carriedOver = 0;
}
let newTask = $state(''); let newTask = $state('');
// Seeded from the class the pre-paint script in index.html already set, so // 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. // there is one source of truth and no post-mount correction to flash.
@@ -474,9 +486,11 @@
return toKey(addDays(fromKey(todayKey), 1)); return toKey(addDays(fromKey(todayKey), 1));
} }
function switchDate() { function showDay(key) {
if (key === selectedKey) return;
cancelEdit(); cancelEdit();
selectedKey = selectedKey === todayKey ? tomorrowKey() : todayKey; dismissUndo();
selectedKey = key;
tasks = storage.loadTasks(selectedKey); tasks = storage.loadTasks(selectedKey);
} }
@@ -494,7 +508,10 @@
const wasViewingToday = selectedKey === todayKey; const wasViewingToday = selectedKey === todayKey;
todayKey = toKey(now); todayKey = toKey(now);
const before = storage.loadTasks(todayKey).length;
const todayTasks = rollover(storage, now); const todayTasks = rollover(storage, now);
const moved = todayTasks.length - before;
if (moved > 0) carriedOver = moved;
if (wasViewingToday) { if (wasViewingToday) {
selectedKey = todayKey; selectedKey = todayKey;
@@ -521,7 +538,6 @@
} }
const currentDateDisplay = $derived(formatLong(selectedKey)); const currentDateDisplay = $derived(formatLong(selectedKey));
const buttonText = $derived(labelFor(selectedKey, todayKey));
const remainingTasks = $derived(tasks.filter(task => !task.completed).length); const remainingTasks = $derived(tasks.filter(task => !task.completed).length);
const completedTasks = $derived(tasks.filter(task => task.completed).length); const completedTasks = $derived(tasks.filter(task => task.completed).length);
@@ -571,15 +587,26 @@
</div> </div>
<div class="header-actions"> <div class="header-actions">
<button class="today-btn" aria-label="Switch between Today and Tomorrow" onclick={switchDate}> <!-- Both options are visible with the current one marked, so the
<svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg"> control no longer has to be clicked to find out what it does. -->
<rect x="3" y="4" width="18" height="18" rx="2" ry="2" stroke="currentColor" stroke-width="2"/> <div class="day-switch" role="group" aria-label="Choose a day">
<line x1="16" y1="2" x2="16" y2="6" stroke="currentColor" stroke-width="2"/> <button
<line x1="8" y1="2" x2="8" y2="6" stroke="currentColor" stroke-width="2"/> class="day-option"
<line x1="3" y1="10" x2="21" y2="10" stroke="currentColor" stroke-width="2"/> class:selected={viewingToday}
</svg> aria-pressed={viewingToday}
{buttonText} onclick={() => showDay(todayKey)}
</button> >
Today
</button>
<button
class="day-option"
class:selected={!viewingToday}
aria-pressed={!viewingToday}
onclick={() => showDay(tomorrowKey())}
>
Tomorrow
</button>
</div>
<button <button
class="theme-toggle" class="theme-toggle"
@@ -634,6 +661,21 @@
{/if} {/if}
</div> </div>
{#if carriedOver > 0 && viewingToday}
<div class="carried-notice">
<span>
{carriedOver === 1
? 'One unfinished task carried over from an earlier day.'
: `${carriedOver} unfinished tasks carried over from earlier days.`}
</span>
<button class="carried-dismiss" onclick={dismissCarriedOver} aria-label="Dismiss">
<svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M18 6L6 18M6 6l12 12" stroke="currentColor" stroke-width="2" stroke-linecap="round"/>
</svg>
</button>
</div>
{/if}
<div class="task-list"> <div class="task-list">
{#key selectedKey} {#key selectedKey}
{#if tasks.length === 0} {#if tasks.length === 0}
-6
View File
@@ -28,12 +28,6 @@ export function isKey(value) {
return KEY_PATTERN.test(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) { export function formatLong(dateKey) {
return fromKey(dateKey).toLocaleDateString('en-US', { return fromKey(dateKey).toLocaleDateString('en-US', {
weekday: 'long', weekday: 'long',
+1 -23
View File
@@ -1,5 +1,5 @@
import { describe, it, expect } from 'vitest' import { describe, it, expect } from 'vitest'
import { toKey, fromKey, addDays, isKey, labelFor, formatLong } from './dates.js' import { toKey, fromKey, addDays, isKey, formatLong } from './dates.js'
describe('toKey', () => { describe('toKey', () => {
it('formats a date as local YYYY-MM-DD', () => { it('formats a date as local YYYY-MM-DD', () => {
@@ -60,28 +60,6 @@ describe('isKey', () => {
}) })
}) })
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', () => { describe('formatLong', () => {
it('renders the long-form date used in the header', () => { it('renders the long-form date used in the header', () => {
expect(formatLong('2026-08-15')).toBe('Saturday, August 15, 2026') expect(formatLong('2026-08-15')).toBe('Saturday, August 15, 2026')
+82 -15
View File
@@ -92,30 +92,45 @@ body {
gap: 16px; gap: 16px;
} }
.today-btn { /* Both days are shown with the current one marked, so the control states
where you are and what the alternative is without being clicked. */
.day-switch {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 8px;
padding: 8px 16px;
background: transparent;
border: 1px solid var(--border); border: 1px solid var(--border);
border-radius: 8px; border-radius: 8px;
color: var(--text-secondary); padding: 3px;
font-size: 14px; gap: 2px;
cursor: pointer;
transition: all 200ms ease;
height: 40px; height: 40px;
transition: border-color 200ms ease;
} }
.today-btn:hover { .day-option {
padding: 0 14px;
height: 100%;
border: none;
border-radius: 6px;
background: transparent;
font-family: inherit;
font-size: 14px;
color: var(--text-secondary);
cursor: pointer;
transition: background-color 200ms ease, color 200ms ease;
white-space: nowrap;
}
.day-option:hover {
color: var(--text-primary);
}
.day-option.selected {
background-color: var(--hover); background-color: var(--hover);
border-color: var(--accent); color: var(--text-primary);
color: var(--accent); font-weight: 500;
} }
.today-btn svg { html.dark .day-option.selected {
width: 16px; background-color: var(--hover);
height: 16px;
} }
.theme-toggle { .theme-toggle {
@@ -604,7 +619,7 @@ html.dark .data-status.error {
transform: translate(-50%, -50%); transform: translate(-50%, -50%);
} }
.today-btn, .day-switch,
.theme-toggle { .theme-toggle {
height: 44px; height: 44px;
} }
@@ -778,3 +793,55 @@ html.dark .undo-toast {
min-height: 44px; min-height: 44px;
} }
} }
/* Says out loud what rollover just did. Without it, opening the app after a
few days away looks like tasks appearing from nowhere. */
.carried-notice {
display: flex;
align-items: center;
gap: 12px;
margin-bottom: 20px;
padding: 10px 10px 10px 14px;
border-radius: 8px;
background-color: var(--completed-bg);
color: var(--completed-text);
font-size: 14px;
}
.carried-notice span {
flex: 1;
min-width: 0;
}
.carried-dismiss {
flex-shrink: 0;
width: 28px;
height: 28px;
display: inline-flex;
align-items: center;
justify-content: center;
border: none;
border-radius: 6px;
background: transparent;
color: inherit;
cursor: pointer;
opacity: 0.7;
transition: opacity 200ms ease, background-color 200ms ease;
}
.carried-dismiss:hover {
opacity: 1;
background-color: var(--hover);
}
.carried-dismiss svg {
width: 16px;
height: 16px;
}
@media (pointer: coarse) {
.carried-dismiss {
width: 44px;
height: 44px;
}
}