Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,10 @@ The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and

## [Unreleased]

### Fixed
- Goal and project deadlines now show the correct local-day countdown in every timezone.
- Finished projects with past target dates now say when they were due instead of showing a late countdown.

## [0.2.0] - 2026-11-08

A calmer, simpler CodeOrbit that works offline and reminds you to back up.
Expand Down
17 changes: 7 additions & 10 deletions assets/js/dashboard.js
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,9 @@

import { storage } from './storage.js';
import {
daysUntil,
escapeHTML,
formatDaysUntil,
formatDuration,
formatRelativeTime,
calculateStreak,
Expand Down Expand Up @@ -327,7 +329,6 @@ function renderComingUp(goals, activities, offerRecap = false) {
if (!container) return;

const rows = [];
const today = getTodayISO();

const nextGoal = goals
.filter(g => g.status === 'Active')
Expand All @@ -336,7 +337,7 @@ function renderComingUp(goals, activities, offerRecap = false) {
if (nextGoal) {
rows.push({
href: '#goals',
kind: goalDueText(nextGoal.deadline, today),
kind: goalDueText(nextGoal.deadline),
title: nextGoal.title,
meta: `${Number(nextGoal.progress) || 0}% done`
});
Expand Down Expand Up @@ -386,13 +387,9 @@ function renderComingUp(goals, activities, offerRecap = false) {
document.getElementById('home-open-recap')?.addEventListener('click', () => openRecap());
}

function goalDueText(deadline, today) {
function goalDueText(deadline) {
if (!deadline) return 'Goal · no due date';
const days = Math.round(
(new Date(`${deadline.slice(0, 10)}T00:00:00`) - new Date(`${today}T00:00:00`)) / 86400000
);
if (Number.isNaN(days)) return 'Goal';
if (days < 0) return `Goal · ${-days} day${days === -1 ? '' : 's'} late`;
if (days === 0) return 'Goal · due today';
return `Goal · due in ${days} day${days === 1 ? '' : 's'}`;
const days = daysUntil(deadline);
if (days === null) return 'Goal';
return `Goal · ${formatDaysUntil(days, { futureStyle: 'in' })}`;
}
23 changes: 11 additions & 12 deletions assets/js/goals.js
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
*/

import { storage } from './storage.js';
import { escapeHTML, formatDate, getTodayISO } from './utils.js';
import { daysUntil, escapeHTML, formatDate, formatDaysUntil, getTodayISO } from './utils.js';
import { openModal, closeModal, toast, showConfirmDialog } from './components.js';

let searchQuery = '';
Expand Down Expand Up @@ -155,24 +155,23 @@ export function renderGoals() {
container.innerHTML = filtered
.map(g => {
const isCompleted = g.status === 'Completed';
const isOverdue = !isCompleted && g.deadline && g.deadline < today;
const daysLeft = g.deadline ? daysUntil(g.deadline) : null;
const isOverdue = !isCompleted && daysLeft !== null && daysLeft < 0;
const id = escapeHTML(g.id);
const progress = Math.max(0, Math.min(100, Number(g.progress) || 0));
const ringCircum = 2 * Math.PI * 20; // 125.66 for r=20
const ringOffset = ringCircum - (progress / 100) * ringCircum;

let deadlineDisplay = 'No due date';
if (g.deadline) {
const daysLeft = Math.ceil((new Date(g.deadline) - new Date()) / (1000 * 60 * 60 * 24));
const text =
daysLeft < 0
? `${-daysLeft} days late`
: daysLeft === 0
? 'Due today'
: `${daysLeft} days left`;
deadlineDisplay = isCompleted
? `Finished &bull; was due ${formatDate(g.deadline)}`
: `${text} &bull; ${formatDate(g.deadline)}`;
if (isCompleted) {
deadlineDisplay = `Finished &bull; was due ${formatDate(g.deadline)}`;
} else if (daysLeft === null) {
deadlineDisplay = formatDate(g.deadline);
} else {
const text = formatDaysUntil(daysLeft, { capitalizeToday: true });
deadlineDisplay = `${text} &bull; ${formatDate(g.deadline)}`;
}
}

return `
Expand Down
24 changes: 15 additions & 9 deletions assets/js/projects.js
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,14 @@
*/

import { storage } from './storage.js';
import { escapeHTML, formatDate, safeUrl, projectStatusLabel } from './utils.js';
import {
daysUntil,
escapeHTML,
formatDate,
formatDaysUntil,
safeUrl,
projectStatusLabel
} from './utils.js';
import { openModal, closeModal, toast, showConfirmDialog } from './components.js';

let searchQuery = '';
Expand Down Expand Up @@ -165,14 +172,13 @@ export function renderProjects() {

let targetDateDisplay = 'No finish date';
if (p.targetDate) {
const daysLeft = Math.ceil((new Date(p.targetDate) - new Date()) / (1000 * 60 * 60 * 24));
const countdown =
daysLeft < 0
? `${-daysLeft} days late`
: daysLeft === 0
? 'due today'
: `${daysLeft} days left`;
targetDateDisplay = `Finish by ${formatDate(p.targetDate)} (${countdown})`;
const daysLeft = daysUntil(p.targetDate);
const formatted = formatDate(p.targetDate);
targetDateDisplay = isCompleted
? `Finished &bull; was due ${formatted}`
: daysLeft === null
? `Finish by ${formatted}`
: `Finish by ${formatted} (${formatDaysUntil(daysLeft)})`;
}

return `
Expand Down
37 changes: 37 additions & 0 deletions assets/js/utils.js
Original file line number Diff line number Diff line change
Expand Up @@ -221,6 +221,43 @@ export function formatRelativeTime(dateStr) {
return formatDate(dateStr);
}

/**
* Whole local calendar days from today until `dateStr` (0 = today, 1 = tomorrow,
* -1 = yesterday). Plain YYYY-MM-DD strings are parsed as local midnight so
* timezones and time-of-day never shift the day count.
* @param {string} dateStr - ISO date string (YYYY-MM-DD or full ISO timestamp)
* @returns {number | null} Integer day difference, or null when missing/invalid
*/
export function daysUntil(dateStr) {
if (typeof dateStr !== 'string') return null;
const trimmed = dateStr.trim();
if (!/^\d{4}-\d{2}-\d{2}(T[\d:.]+(Z|[+-]\d{2}:?\d{2})?)?$/.test(trimmed)) return null;
if (trimmed.length > 10 && Number.isNaN(new Date(trimmed).getTime())) return null;

const dayStr = trimmed.slice(0, 10);
const target = new Date(`${dayStr}T00:00:00`);
if (Number.isNaN(target.getTime()) || toLocalISO(target) !== dayStr) return null;

const today = new Date(`${getTodayISO()}T00:00:00`);
const days = Math.round((target - today) / 86400000);
return days === 0 ? 0 : days;
}

/**
* Formats a day difference from daysUntil() as a plain-language countdown
* (e.g. '1 day left', '2 days late', 'due today', or 'due in 1 day').
* @param {number | null} days - Integer day offset returned by daysUntil()
* @param {{ capitalizeToday?: boolean, futureStyle?: 'left' | 'in' }} [options]
* @returns {string} Countdown label, or '' when days is null/invalid
*/
export function formatDaysUntil(days, { capitalizeToday = false, futureStyle = 'left' } = {}) {
if (!Number.isFinite(days)) return '';
const unit = n => `${n} day${n === 1 ? '' : 's'}`;
if (days < 0) return `${unit(-days)} late`;
if (days === 0) return capitalizeToday ? 'Due today' : 'due today';
return futureStyle === 'in' ? `due in ${unit(days)}` : `${unit(days)} left`;
}

/**
* Formats duration in minutes into a human-friendly string (e.g. "1h 45m" or "45m")
* @param {number} minutes - Duration in minutes
Expand Down
8 changes: 4 additions & 4 deletions docs/FLOW.md
Original file line number Diff line number Diff line change
Expand Up @@ -114,11 +114,11 @@ Every page module follows the same shape: **`initX()` attaches listeners only; `

| Module | `init` attaches | `render` reads | `render` calls |
|---|---|---|---|
| `dashboard.js` (Home) | *Add study time* → `openActivityModal`; *+30 min on …* → `quickAdd` / *Undo* → `undoQuickAdd`; backup reminder *Download backup* → `downloadBackup`, *Not now* → `saveSettings({ backupSnoozeUntil })`; first visit *Add your first skill* → `openSkillModal`, *Try it with sample data first* → `storage.loadSampleData()` | whole data set | nothing saved at all (no skills, sessions, projects or goals)? show `#home-empty` and stop; else `renderGreeting`, `renderWeek` (`isWithinLastNDays`, `calculateStreak`, `streakMessage`), `renderQuickAdd`, `renderOrbit` (DOM APIs, no `innerHTML`), `renderDays` (`getLastNDaysISO`), `renderComingUp` (+ the week-in-review row when `shouldOfferRecap`), `renderBackupReminder` (`backupReminder`) |
| `dashboard.js` (Home) | *Add study time* → `openActivityModal`; *+30 min on …* → `quickAdd` / *Undo* → `undoQuickAdd`; backup reminder *Download backup* → `downloadBackup`, *Not now* → `saveSettings({ backupSnoozeUntil })`; first visit *Add your first skill* → `openSkillModal`, *Try it with sample data first* → `storage.loadSampleData()` | whole data set | nothing saved at all (no skills, sessions, projects or goals)? show `#home-empty` and stop; else `renderGreeting`, `renderWeek` (`isWithinLastNDays`, `calculateStreak`, `streakMessage`), `renderQuickAdd`, `renderOrbit` (DOM APIs, no `innerHTML`), `renderDays` (`getLastNDaysISO`), `renderComingUp` → `goalDueText` → `daysUntil` and `formatDaysUntil` (+ the week-in-review row when `shouldOfferRecap`), `renderBackupReminder` (`backupReminder`) |
| `skills.js` | Search, category filter, status pills, sort, Add button, form submit, progress slider label | `skills` | per-card `+10%/−10%` → `stepSkillProgress`, ⋯ menu → `showSkillContextMenu` → edit (`openSkillModal`) / delete (`showConfirmDialog` → `storage.deleteSkill`) |
| `progress.js` (Study sessions) | Search, type filter, skill filter, sort, *Add study time* button, form submit | `activities`, `skills`, `settings` | `populateSkillFilterOptions`, `openActivityModal`, delete via `showConfirmDialog` |
| `projects.js` | Search, status pills, sort, Add button, form submit, progress slider label, "Tools used" input | `projects` | `renderModalTags`, `openProjectModal`, delete via `showConfirmDialog`; links through `safeUrl` |
| `goals.js` | Search, status pills, priority filter, Add button, form submit, progress slider label | `goals` | `toggleGoalComplete`, `openGoalModal`, delete via `showConfirmDialog` |
| `projects.js` | Search, status pills, sort, Add button, form submit, progress slider label, "Tools used" input | `projects` | `renderProjects` → `daysUntil` and `formatDaysUntil`; `renderModalTags`, `openProjectModal`, delete via `showConfirmDialog`; links through `safeUrl` |
| `goals.js` | Search, status pills, priority filter, Add button, form submit, progress slider label | `goals` | `renderGoals` → `daysUntil` and `formatDaysUntil`; `toggleGoalComplete`, `openGoalModal`, delete via `showConfirmDialog` |
| `achievements.js` (Badges) | Group pills (no `data-changed` listener of its own) | `achievements` + `ACHIEVEMENT_DEFINITIONS` | (unlocking happens in `app.js`, see §6) |
| `analytics.js` (Progress) | *How is this worked out?* → `openModal('score-breakdown-modal')` | whole data set | `calculateProgressScore`, `populateScoreModal`, badges count, `calculateStreak`, 5 × `chartManager.render…Chart`, `generateHabitInsights` |
| `settings.js` | Section buttons, profile form, preferences form, profile pictures, *Download backup* (`downloadBackup`), *Restore from backup*, *Load sample data*, *Delete all my data*, *Install CodeOrbit on this device*; **fills the top-bar profile picture** | `profile`, `settings` | `updateTopbarProfile` |
Expand Down Expand Up @@ -243,7 +243,7 @@ navigation.js, utils.js, icons.js ──► (none)

## 10. Rules that keep this flow correct

1. **Calendar days are local, never UTC.** Use `getTodayISO()`, `getRelativeISO(n)`, `toLocalISO(date)`, `getLastNDaysISO(n)` and `isWithinLastNDays(dateStr, n)` from `utils.js`. Never use `date.toISOString().slice(0, 10)` for a day: it is UTC and shifts the date for users east or west of Greenwich (in India, every day before 05:30).
1. **Calendar days are local, never UTC.** Use `getTodayISO()`, `getRelativeISO(n)`, `toLocalISO(date)`, `getLastNDaysISO(n)`, `isWithinLastNDays(dateStr, n)`, `daysUntil(dateStr)` and `formatDaysUntil(days)` from `utils.js`. `daysUntil(dateStr)` returns the whole-day difference using local calendar days, or `null` for missing or invalid dates. `formatDaysUntil(days)` formats the countdown label consistently. Never use `date.toISOString().slice(0, 10)` for a day: it is UTC and shifts the date for users east or west of Greenwich (in India, every day before 05:30).
2. **`init` functions never render.** The router renders the visible page; rendering hidden pages at startup is wasted work.
3. **Only `app.js` listens to `data-changed` for re-rendering.** Pages must not add their own listener; they will be re-rendered when visible.
4. **One user action = one save.** If a handler needs to change several things, change them together and call `storage` once.
Expand Down
118 changes: 118 additions & 0 deletions tests/utils.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@ import {
escapeHTML,
formatDate,
formatRelativeTime,
daysUntil,
formatDaysUntil,
toLocalISO,
getTodayISO,
getRelativeISO,
Expand Down Expand Up @@ -183,6 +185,122 @@ describe('local calendar dates', () => {
});
});

describe('daysUntil', () => {
test('returns 0 (never -0) for due today early in the morning', t => {
freezeTime(t, new Date(2026, 10, 7, 0, 5, 0));
assert.equal(daysUntil('2026-11-07'), 0);
assert.equal(daysUntil('2026-11-08'), 1);
assert.equal(daysUntil('2026-11-06'), -1);
});

test('returns 0 (never -0) for due today late in the evening', t => {
freezeTime(t, new Date(2026, 10, 7, 23, 55, 0));
assert.equal(daysUntil('2026-11-07'), 0);
assert.equal(daysUntil('2026-11-08'), 1);
assert.equal(daysUntil('2026-11-06'), -1);
});

// Regression for issue #8: 04:00 IST (UTC+5:30) and 20:00 PST (UTC-8) used to shift by 1 day.
test('matches local calendar days at 04:00 IST', t => {
freezeTime(t, '2026-11-07T04:00:00+05:30'); // 2026-11-06 22:30 UTC
assert.equal(daysUntil(getTodayISO()), 0);
if (TZ === 'Asia/Kolkata') {
assert.equal(daysUntil('2026-11-07'), 0);
assert.equal(daysUntil('2026-11-08'), 1);
assert.equal(daysUntil('2026-11-06'), -1);
}
});

test('matches local calendar days at 20:00 PST', t => {
freezeTime(t, '2026-11-07T20:00:00-08:00'); // 2026-11-08 04:00 UTC
assert.equal(daysUntil(getTodayISO()), 0);
if (TZ === 'America/Los_Angeles') {
assert.equal(daysUntil('2026-11-07'), 0);
assert.equal(daysUntil('2026-11-08'), 1);
assert.equal(daysUntil('2026-11-06'), -1);
}
});

test('counts across month, leap-day, and multi-day boundaries', t => {
freezeTime(t, localNoon(2028, 2, 28));
assert.equal(daysUntil('2028-02-29'), 1);
assert.equal(daysUntil('2028-03-01'), 2);
assert.equal(daysUntil('2028-02-25'), -3);
});

test('counts across the year-end boundary', t => {
freezeTime(t, localNoon(2026, 12, 31));
assert.equal(daysUntil('2027-01-01'), 1);
assert.equal(daysUntil('2027-01-05'), 5);
assert.equal(daysUntil('2026-12-30'), -1);
});

test('stays accurate across spring-forward DST transition', t => {
// US clocks spring forward on 2026-03-08 (23-hour day)
freezeTime(t, new Date(2026, 2, 8, 1, 30));
assert.equal(daysUntil('2026-03-07'), -1);
assert.equal(daysUntil('2026-03-08'), 0);
assert.equal(daysUntil('2026-03-09'), 1);
});

test('stays accurate across fall-back DST transition', t => {
// US clocks fall back on 2026-11-01 (25-hour day)
freezeTime(t, new Date(2026, 10, 1, 0, 30));
assert.equal(daysUntil('2026-10-31'), -1);
assert.equal(daysUntil('2026-11-01'), 0);
assert.equal(daysUntil('2026-11-02'), 1);
});

test('accepts full ISO timestamps and trims surrounding whitespace', t => {
freezeTime(t, localNoon(2026, 11, 7));
assert.equal(daysUntil(' 2026-11-08T23:59:59.000Z '), 1);
assert.equal(daysUntil('2026-11-07T12:00:00+05:30'), 0);
});

test('returns null for missing, invalid, or overflowing calendar dates', t => {
freezeTime(t, localNoon(2026, 11, 7));
for (const bad of [
'',
' ',
null,
undefined,
42,
{},
'not-a-date',
'2026-13-01',
'2026-02-30',
'2026-02-29',
'2026-11-07foo',
'2026-11-07T25:00:00Z',
'<img src=x onerror=alert(1)>'
]) {
assert.equal(daysUntil(bad), null, String(bad));
}
});
});

describe('formatDaysUntil', () => {
test('uses singular "day" for 1 and -1, plural "days" otherwise', () => {
assert.equal(formatDaysUntil(1), '1 day left');
assert.equal(formatDaysUntil(3), '3 days left');
assert.equal(formatDaysUntil(-1), '1 day late');
assert.equal(formatDaysUntil(-2), '2 days late');
assert.equal(formatDaysUntil(1, { futureStyle: 'in' }), 'due in 1 day');
assert.equal(formatDaysUntil(3, { futureStyle: 'in' }), 'due in 3 days');
});

test('formats 0 as due today with optional capitalization', () => {
assert.equal(formatDaysUntil(0), 'due today');
assert.equal(formatDaysUntil(0, { capitalizeToday: true }), 'Due today');
});

test('returns empty string for null or non-finite values', () => {
for (const bad of [null, undefined, NaN, Infinity, -Infinity]) {
assert.equal(formatDaysUntil(bad), '');
}
});
});

describe('peakWeekday', () => {
test('a session on a known Saturday counts as Saturday in every timezone', () => {
// 2026-11-07 is a Saturday on the calendar. Parsing the plain date as UTC
Expand Down
Loading