Skip to content
Closed
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
13 changes: 10 additions & 3 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,8 +17,8 @@ This repository is a **static GitHub Pages site**. There is no .NET / MAUI app,
- Vanilla HTML/CSS/JS. Classic `<script>` tags (not ES modules) so `file://` and Pages both work.
- Time format: results use `h:mm` (minutes always two digits). Worked time is two numeric fields (hours, minutes 0–59) so mobile keyboards do not need `:`.
- Default start date: 1 January of the current calendar year (`app.js`). Default end date: today.
- Working days: Monday–Friday minus ISO dates in `HOLIDAYS` (`worktime.js`). Compare `YYYY-MM-DD` strings; iterate in UTC.
- Expected hours: **8 per working day** (`HOURS_PER_DAY`).
- Working days: Monday–Friday minus holidays for every year overlapping the range (`holidaysForYear` in `worktime.js`). Compare `YYYY-MM-DD` strings; iterate in UTC.
- Expected hours: **8 per working day** (`HOURS_PER_DAY`). A full non-leap year where all configured holidays fall on weekdays is **252 days / 2016 hours**, not 261 weekdays / 2088 hours.
- UI language: Spanish. README and this file: English.
- Commit messages: English.

Expand All @@ -35,7 +35,13 @@ GitHub Pages URL: `https://cesarordazv.github.io/WorktimeCalculator/`

### Add or update holidays

Edit the `HOLIDAYS` array in `worktime.js`. Use `YYYY-MM-DD`. Holidays are year-specific; add a new year explicitly.
Edit `holidaysForYear` in `worktime.js`. Holidays are computed for each year in the selected range (do not keep a per-year date list):

- LFT: 1 January, first Monday of February, third Monday of March, 1 May, 16 September, third Monday of November, 25 December
- Holy Thursday and Good Friday (Easter minus 3 and 2 days)
- 1 October on presidential inauguration years (every six years from 2024)

A full 2026 calendar year must be 252 working days / **2016** hours. 2088 hours means weekdays were counted and holidays were not subtracted.

### Change hours per day

Expand All @@ -53,6 +59,7 @@ Edit `HOURS_PER_DAY` in `worktime.js`.
- Invalid hours/minutes (minutes over 59) and start-after-end must show an error, not silent zeros.
- Relative asset paths only (`styles.css`, not `/styles.css`) so the project Pages URL works.
- Do not reintroduce a MAUI/XAML app unless explicitly requested.
- 2088 hours for a full year means holidays were not applied (261 weekdays × 8). The correct total with the current holiday rules is 2016.

## Out of scope (unless explicitly requested)

Expand Down
11 changes: 4 additions & 7 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -32,23 +32,20 @@ Alternative: **Deploy from a branch** → `main` → `/` (root). Asset paths are
## Rules

- Date range is **inclusive**.
- A working day is Monday–Friday and **not** in `HOLIDAYS` (`worktime.js`).
- Expected hours = `8 × working days` (the main result).
- A working day is Monday–Friday minus holidays computed for the years in the range (`holidaysForYear` in `worktime.js`).
- Expected hours = `8 × working days` (the main result). A full 2026 year is **2016:00** (252 days), not 2088:00 (261 weekdays with no holidays).
- Difference = hours worked − expected (minus sign and red when behind).
- Daily average = worked ÷ working days (`—` if there are no working days).
- Time format: results use `h:mm` with two-digit minutes. Input is two numeric fields (hours and minutes), so Android can use the number keypad.

Holidays are year-specific (Mexican federal holidays plus Holy Thursday and Good Friday):
Holidays follow Mexican LFT rules (fixed dates and movable Mondays) plus Holy Thursday and Good Friday, generated for each year in the range. 2026 dates:

```
2025-01-01 2025-02-03 2025-03-17 2025-04-17 2025-04-18
2025-05-01 2025-09-16 2025-11-18 2025-12-25

2026-01-01 2026-02-02 2026-03-16 2026-04-02 2026-04-03
2026-05-01 2026-09-16 2026-11-16 2026-12-25
```

Add more ISO dates (`YYYY-MM-DD`) to `HOLIDAYS` in `worktime.js`. Change hours per day with `HOURS_PER_DAY` in the same file.
Change hours per day with `HOURS_PER_DAY` in `worktime.js`.

## Files

Expand Down
46 changes: 46 additions & 0 deletions app.js
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
var expectedEl = document.getElementById("expected-hours");
var differenceEl = document.getElementById("hours-difference");
var dailyEl = document.getElementById("daily-hours");
var holidayNoteEl = document.getElementById("holiday-note");

function todayIso() {
var now = new Date();
Expand All @@ -31,12 +32,55 @@
if (!minutesInput.value) minutesInput.value = "0";
}

var MONTHS_SHORT = [
"ene",
"feb",
"mar",
"abr",
"may",
"jun",
"jul",
"ago",
"sep",
"oct",
"nov",
"dic",
];

function holidayLabel(iso) {
var parts = iso.split("-");
var d = Number(parts[2]);
var month = MONTHS_SHORT[Number(parts[1]) - 1];
return d + " " + month;
}

function updateHolidayNote(startIso, endIso) {
if (!holidayNoteEl || !Worktime.holidaysForRange) return;
var startYear = Number(String(startIso).slice(0, 4));
var endYear = Number(String(endIso).slice(0, 4));
if (!startYear) {
holidayNoteEl.textContent = "Los festivos se calculan para el año del rango.";
return;
}
var year = startYear;
var list = Worktime.holidaysForYear(year);
var labels = [];
for (var i = 0; i < list.length; i++) labels.push(holidayLabel(list[i]));
var extra =
endYear && endYear !== startYear
? " El rango cruza de " + startYear + " a " + endYear + "."
: "";
holidayNoteEl.textContent =
"Festivos " + year + ": " + labels.join(", ") + "." + extra;
}

function showError(message) {
errorEl.hidden = !message;
errorEl.textContent = message || "";
}

function render() {
updateHolidayNote(startInput.value, endInput.value);
var parts = Worktime.parseDurationParts(hoursInput.value, minutesInput.value);
var result = Worktime.calculate(
startInput.value,
Expand Down Expand Up @@ -81,7 +125,9 @@
});

startInput.addEventListener("change", render);
startInput.addEventListener("input", render);
endInput.addEventListener("change", render);
endInput.addEventListener("input", render);
hoursInput.addEventListener("input", render);
hoursInput.addEventListener("change", render);
minutesInput.addEventListener("input", render);
Expand Down
10 changes: 4 additions & 6 deletions index.html
Original file line number Diff line number Diff line change
Expand Up @@ -82,10 +82,8 @@ <h1>Horas laborales</h1>
<dd id="daily-hours">0:00</dd>
</div>
</dl>
<p class="note">
Festivos 2026: 1 ene, 2 feb, 16 mar, 2–3 abr, 1 may, 16 sep, 16 nov,
25 dic. También hay fechas de 2025. Edita la lista en
<code>worktime.js</code>.
<p class="note" id="holiday-note">
Festivos oficiales (LFT) y Jueves/Viernes Santo del año del rango.
</p>
</section>

Expand All @@ -96,7 +94,7 @@ <h1>Horas laborales</h1>
</footer>
</main>

<script src="worktime.js"></script>
<script src="app.js"></script>
<script src="worktime.js?v=2026-08-26-year"></script>
<script src="app.js?v=2026-08-26-year"></script>
</body>
</html>
101 changes: 71 additions & 30 deletions worktime.js
Original file line number Diff line number Diff line change
Expand Up @@ -6,35 +6,15 @@
"use strict";

var HOURS_PER_DAY = 8;

// Year-specific Mexican federal holidays plus Holy Thursday and Good Friday.
var HOLIDAYS = [
"2025-01-01",
"2025-02-03",
"2025-03-17",
"2025-04-17",
"2025-04-18",
"2025-05-01",
"2025-09-16",
"2025-11-18",
"2025-12-25",
"2026-01-01",
"2026-02-02",
"2026-03-16",
"2026-04-02",
"2026-04-03",
"2026-05-01",
"2026-09-16",
"2026-11-16",
"2026-12-25",
];
var MS_PER_DAY = 24 * 60 * 60 * 1000;

function parseIsoDate(iso) {
var parts = String(iso).split("-");
if (parts.length !== 3) return null;
var y = Number(parts[0]);
var m = Number(parts[1]);
var d = Number(parts[2]);
var raw = String(iso == null ? "" : iso).trim();
var match = /^(\d{4})-(\d{2})-(\d{2})/.exec(raw);
if (!match) return null;
var y = Number(match[1]);
var m = Number(match[2]);
var d = Number(match[3]);
if (!y || m < 1 || m > 12 || d < 1 || d > 31) return null;
return Date.UTC(y, m - 1, d);
}
Expand All @@ -43,18 +23,78 @@
return new Date(utcMs).toISOString().slice(0, 10);
}

/** n-th Monday of a month (monthIndex 0–11). */
function nthMonday(year, monthIndex, n) {
var firstDow = new Date(Date.UTC(year, monthIndex, 1)).getUTCDay();
var offset = (1 - firstDow + 7) % 7;
return toIsoDate(Date.UTC(year, monthIndex, 1 + offset + (n - 1) * 7));
}

/** Easter Sunday (Anonymous Gregorian algorithm), UTC midnight. */
function easterSundayUtc(year) {
var a = year % 19;
var b = Math.floor(year / 100);
var c = year % 100;
var d = Math.floor(b / 4);
var e = b % 4;
var f = Math.floor((b + 8) / 25);
var g = Math.floor((b - f + 1) / 3);
var h = (19 * a + b - d - g + 15) % 30;
var i = Math.floor(c / 4);
var k = c % 4;
var l = (32 + 2 * e + 2 * i - h - k) % 7;
var m = Math.floor((a + 11 * h + 22 * l) / 451);
var month = Math.floor((h + l - 7 * m + 114) / 31);
var day = ((h + l - 7 * m + 114) % 31) + 1;
return Date.UTC(year, month - 1, day);
}

/**
* Mexican LFT rest days for a calendar year, plus Holy Thursday and Good Friday.
* Presidential inauguration (1 Oct every six years from 2024) is included when it applies.
*/
function holidaysForYear(year) {
var easter = easterSundayUtc(year);
var list = [
toIsoDate(Date.UTC(year, 0, 1)),
nthMonday(year, 1, 1),
nthMonday(year, 2, 3),
toIsoDate(easter - 3 * MS_PER_DAY),
toIsoDate(easter - 2 * MS_PER_DAY),
toIsoDate(Date.UTC(year, 4, 1)),
toIsoDate(Date.UTC(year, 8, 16)),
nthMonday(year, 10, 3),
toIsoDate(Date.UTC(year, 11, 25)),
];
if (year >= 2024 && (year - 2024) % 6 === 0) {
list.push(toIsoDate(Date.UTC(year, 9, 1)));
}
return list;
}

function holidaysForRange(startIso, endIso) {
var startYear = Number(String(startIso).slice(0, 4));
var endYear = Number(String(endIso).slice(0, 4));
if (!startYear || !endYear || startYear > endYear) return [];
var list = [];
for (var y = startYear; y <= endYear; y++) {
list = list.concat(holidaysForYear(y));
}
return list;
}

function countWorkingDays(startIso, endIso, holidays) {
var start = parseIsoDate(startIso);
var end = parseIsoDate(endIso);
if (start == null || end == null) return null;
if (start > end) return null;

var holidaySet = {};
var list = holidays || HOLIDAYS;
var list = holidays != null ? holidays : holidaysForRange(startIso, endIso);
for (var i = 0; i < list.length; i++) holidaySet[list[i]] = true;

var days = 0;
for (var t = start; t <= end; t += 24 * 60 * 60 * 1000) {
for (var t = start; t <= end; t += MS_PER_DAY) {
var iso = toIsoDate(t);
var dow = new Date(t).getUTCDay(); // 0 Sun … 6 Sat
if (dow === 0 || dow === 6 || holidaySet[iso]) continue;
Expand Down Expand Up @@ -125,7 +165,8 @@

var api = {
HOURS_PER_DAY: HOURS_PER_DAY,
HOLIDAYS: HOLIDAYS,
holidaysForYear: holidaysForYear,
holidaysForRange: holidaysForRange,
parseIsoDate: parseIsoDate,
countWorkingDays: countWorkingDays,
expectedHours: expectedHours,
Expand Down
27 changes: 27 additions & 0 deletions worktime.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,33 @@ test("Nov 16 2026 (Mon holiday) is excluded", () => {
assert.equal(W.countWorkingDays("2026-11-16", "2026-11-16"), 0);
});

test("full year 2026 is 252 working days / 2016 hours, not 261 / 2088", () => {
assert.equal(W.countWorkingDays("2026-01-01", "2026-12-31"), 252);
assert.equal(W.expectedHours("2026-01-01", "2026-12-31"), 2016);
assert.equal(W.formatHmm(W.expectedHours("2026-01-01", "2026-12-31")), "2016:00");
assert.equal(W.countWorkingDays("2026-01-01", "2026-12-31", []), 261);
assert.equal(W.expectedHours("2026-01-01", "2026-12-31", [], 8), 2088);
});

test("full year 2025 is also 252 working days / 2016 hours", () => {
assert.equal(W.countWorkingDays("2025-01-01", "2025-12-31"), 252);
assert.equal(W.expectedHours("2025-01-01", "2025-12-31"), 2016);
});

test("2026 holidays follow LFT Mondays plus Holy Week", () => {
assert.deepEqual(W.holidaysForYear(2026), [
"2026-01-01",
"2026-02-02",
"2026-03-16",
"2026-04-02",
"2026-04-03",
"2026-05-01",
"2026-09-16",
"2026-11-16",
"2026-12-25",
]);
});

test("start after end is invalid", () => {
assert.equal(W.countWorkingDays("2025-02-01", "2025-01-01"), null);
const r = W.calculate("2025-02-01", "2025-01-01", "8:00");
Expand Down
Loading