From 1a73d966eb620df38cf5ab7062993243e587f514 Mon Sep 17 00:00:00 2001 From: abhinna123 Date: Wed, 11 Nov 2026 19:21:12 +0530 Subject: [PATCH 1/3] fix: show correct day for deadlines in every timezone --- assets/js/dashboard.js | 17 +++--- assets/js/goals.js | 23 ++++---- assets/js/projects.js | 24 +++++---- assets/js/utils.js | 37 +++++++++++++ tests/utils.test.js | 118 +++++++++++++++++++++++++++++++++++++++++ 5 files changed, 188 insertions(+), 31 deletions(-) diff --git a/assets/js/dashboard.js b/assets/js/dashboard.js index b3401c9..9c3bd48 100644 --- a/assets/js/dashboard.js +++ b/assets/js/dashboard.js @@ -14,7 +14,9 @@ import { storage } from './storage.js'; import { + daysUntil, escapeHTML, + formatDaysUntil, formatDuration, formatRelativeTime, calculateStreak, @@ -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') @@ -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` }); @@ -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' })}`; } diff --git a/assets/js/goals.js b/assets/js/goals.js index a7b8b55..be63532 100644 --- a/assets/js/goals.js +++ b/assets/js/goals.js @@ -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 = ''; @@ -155,7 +155,8 @@ 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 @@ -163,16 +164,14 @@ export function renderGoals() { 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 • was due ${formatDate(g.deadline)}` - : `${text} • ${formatDate(g.deadline)}`; + if (isCompleted) { + deadlineDisplay = `Finished • was due ${formatDate(g.deadline)}`; + } else if (daysLeft === null) { + deadlineDisplay = formatDate(g.deadline); + } else { + const text = formatDaysUntil(daysLeft, { capitalizeToday: true }); + deadlineDisplay = `${text} • ${formatDate(g.deadline)}`; + } } return ` diff --git a/assets/js/projects.js b/assets/js/projects.js index 21863e2..76fdb4b 100644 --- a/assets/js/projects.js +++ b/assets/js/projects.js @@ -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 = ''; @@ -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 • was due ${formatted}` + : daysLeft === null + ? `Finish by ${formatted}` + : `Finish by ${formatted} (${formatDaysUntil(daysLeft)})`; } return ` diff --git a/assets/js/utils.js b/assets/js/utils.js index bb6b2b4..6831920 100644 --- a/assets/js/utils.js +++ b/assets/js/utils.js @@ -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 diff --git a/tests/utils.test.js b/tests/utils.test.js index caffa91..7056c87 100644 --- a/tests/utils.test.js +++ b/tests/utils.test.js @@ -9,6 +9,8 @@ import { escapeHTML, formatDate, formatRelativeTime, + daysUntil, + formatDaysUntil, toLocalISO, getTodayISO, getRelativeISO, @@ -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', + '' + ]) { + 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 From d4cb281029f972d8ec52251522d0ce5b5a5708bc Mon Sep 17 00:00:00 2001 From: abhinna123 Date: Wed, 11 Nov 2026 19:57:42 +0530 Subject: [PATCH 2/3] docs: add issue 8 fixes to changelog --- CHANGELOG.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index bbd284f..673c302 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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. From af11c29e7a6c20ef74f5286197c0a749f06f1430 Mon Sep 17 00:00:00 2001 From: abhinna123 Date: Wed, 11 Nov 2026 20:57:13 +0530 Subject: [PATCH 3/3] docs: document issue 8 deadline helper flow --- docs/FLOW.md | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/docs/FLOW.md b/docs/FLOW.md index 913d466..c00f4e7 100644 --- a/docs/FLOW.md +++ b/docs/FLOW.md @@ -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` | @@ -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.