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
5 changes: 3 additions & 2 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,8 @@ This repository is a **static GitHub Pages site**. There is no .NET / MAUI app,
## Conventions

- Vanilla HTML/CSS/JS. Classic `<script>` tags (not ES modules) so `file://` and Pages both work.
- Time format: `h:mm` (minutes always two digits).
- 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`).
- UI language: Spanish. README and this file: English.
Expand Down Expand Up @@ -49,7 +50,7 @@ Edit `HOURS_PER_DAY` in `worktime.js`.
## Pitfalls

- Zero working days: do not divide; show `—` for the daily average.
- Invalid `h:mm` and start-after-end must show an error, not silent zeros.
- 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.

Expand Down
4 changes: 3 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,8 @@ python3 -m http.server 8080

Then visit `http://localhost:8080`.

The start date defaults to **1 January of the current year**; the end date defaults to today.

## GitHub Pages

1. Merge this to `main`.
Expand All @@ -34,7 +36,7 @@ Alternative: **Deploy from a branch** → `main` → `/` (root). Asset paths are
- Expected hours = `8 × working days` (the main result).
- Difference = hours worked − expected (minus sign and red when behind).
- Daily average = worked ÷ working days (`—` if there are no working days).
- Time format: `h:mm` with two-digit minutes.
- 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 (2025, carried over from the former MAUI app):

Expand Down
28 changes: 21 additions & 7 deletions app.js
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,8 @@
var form = document.getElementById("calc-form");
var startInput = document.getElementById("start-date");
var endInput = document.getElementById("end-date");
var hoursInput = document.getElementById("worked-hours");
var hoursInput = document.getElementById("worked-h");
var minutesInput = document.getElementById("worked-m");
var errorEl = document.getElementById("form-error");
var daysEl = document.getElementById("working-days");
var expectedEl = document.getElementById("expected-hours");
Expand All @@ -19,10 +20,15 @@
return y + "-" + m + "-" + d;
}

function yearStartIso() {
return new Date().getFullYear() + "-01-01";
}

function setDefaults() {
if (!startInput.value) startInput.value = "2024-12-30";
if (!startInput.value) startInput.value = yearStartIso();
if (!endInput.value) endInput.value = todayIso();
if (!hoursInput.value) hoursInput.value = "0:00";
if (!hoursInput.value) hoursInput.value = "0";
if (!minutesInput.value) minutesInput.value = "0";
}

function showError(message) {
Expand All @@ -31,7 +37,12 @@
}

function render() {
var result = Worktime.calculate(startInput.value, endInput.value, hoursInput.value);
var parts = Worktime.parseDurationParts(hoursInput.value, minutesInput.value);
var result = Worktime.calculate(
startInput.value,
endInput.value,
parts ? parts.hmm : "invalid"
);

if (!result.ok && result.error === "range") {
showError("La fecha inicial no puede ser posterior a la final.");
Expand All @@ -43,9 +54,9 @@
return;
}

if (!result.ok && result.error === "hours") {
showError("Usa el formato h:mm, por ejemplo 160:30.");
daysEl.textContent = String(result.days);
if (!parts || (!result.ok && result.error === "hours")) {
showError("Usa horas enteras y minutos de 0 a 59.");
daysEl.textContent = result.days != null ? String(result.days) : "—";
expectedEl.textContent = Worktime.formatHmm(result.expected);
differenceEl.textContent = "—";
differenceEl.classList.remove("is-behind");
Expand All @@ -72,4 +83,7 @@
startInput.addEventListener("change", render);
endInput.addEventListener("change", render);
hoursInput.addEventListener("input", render);
hoursInput.addEventListener("change", render);
minutesInput.addEventListener("input", render);
minutesInput.addEventListener("change", render);
})();
37 changes: 28 additions & 9 deletions index.html
Original file line number Diff line number Diff line change
Expand Up @@ -30,15 +30,34 @@ <h1>Horas laborales</h1>
<input id="end-date" name="end" type="date" required />
</div>
<div class="field">
<label for="worked-hours">Horas trabajadas (h:mm)</label>
<input
id="worked-hours"
name="worked"
type="text"
inputmode="numeric"
placeholder="160:30"
autocomplete="off"
/>
<span class="group-label" id="worked-label">Horas trabajadas</span>
<div class="duration" role="group" aria-labelledby="worked-label">
<div class="field nested">
<label for="worked-h">Horas</label>
<input
id="worked-h"
name="workedHours"
type="number"
inputmode="numeric"
min="0"
step="1"
autocomplete="off"
/>
</div>
<div class="field nested">
<label for="worked-m">Minutos</label>
<input
id="worked-m"
name="workedMinutes"
type="number"
inputmode="numeric"
min="0"
max="59"
step="1"
autocomplete="off"
/>
</div>
</div>
</div>
<button type="submit">Calcular</button>
<p id="form-error" class="error" hidden></p>
Expand Down
17 changes: 15 additions & 2 deletions styles.css
Original file line number Diff line number Diff line change
Expand Up @@ -61,14 +61,27 @@ form,
margin-bottom: 1rem;
}

label {
label,
.group-label {
font-size: 0.92rem;
font-weight: 600;
}

.duration {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 0.75rem;
}

.field.nested {
margin-bottom: 0;
}

input[type="date"],
input[type="text"] {
input[type="text"],
input[type="number"] {
font: inherit;
width: 100%;
padding: 0.5rem 0.65rem;
border: 1px solid var(--line);
border-radius: 8px;
Expand Down
21 changes: 20 additions & 1 deletion worktime.js
Original file line number Diff line number Diff line change
Expand Up @@ -61,7 +61,25 @@
return days * perDay;
}

/** Parse "h:mm" into decimal hours. Empty string → 0. Invalid → null. */
/** Hours + minutes fields (Android numeric keypad). Empty → 0. Minutes must be 0–59. */
function parseDurationParts(hoursRaw, minutesRaw) {
function asInt(raw) {
var s = String(raw == null ? "" : raw).trim();
if (s === "") return 0;
if (!/^\d+$/.test(s)) return null;
return Number(s);
}
var hours = asInt(hoursRaw);
var minutes = asInt(minutesRaw);
if (hours == null || minutes == null || minutes > 59) return null;
return {
hours: hours,
minutes: minutes,
totalHours: hours + minutes / 60,
hmm: hours + ":" + String(minutes).padStart(2, "0"),
};
}

function parseHmm(text) {
var raw = String(text == null ? "" : text).trim();
if (raw === "") return 0;
Expand Down Expand Up @@ -103,6 +121,7 @@
countWorkingDays: countWorkingDays,
expectedHours: expectedHours,
parseHmm: parseHmm,
parseDurationParts: parseDurationParts,
formatHmm: formatHmm,
calculate: calculate,
};
Expand Down
9 changes: 9 additions & 0 deletions worktime.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,15 @@ test("start after end is invalid", () => {
assert.equal(r.error, "range");
});

test("hours and minutes fields reject minutes over 59", () => {
assert.equal(W.parseDurationParts("160", "30").hmm, "160:30");
assert.equal(W.parseDurationParts("", "").totalHours, 0);
assert.equal(W.parseDurationParts("8", "").totalHours, 8);
assert.equal(W.parseDurationParts("0", "45").totalHours, 0.75);
assert.equal(W.parseDurationParts("10", "60"), null);
assert.equal(W.parseDurationParts("1.5", "0"), null);
});

test("parse and format h:mm", () => {
assert.equal(W.parseHmm("160:30"), 160.5);
assert.equal(W.parseHmm(""), 0);
Expand Down
Loading