diff --git a/working-hours-calculator/app.js b/working-hours-calculator/app.js index fe870c5..cb1fd7c 100644 --- a/working-hours-calculator/app.js +++ b/working-hours-calculator/app.js @@ -1,458 +1,388 @@ -const STORAGE_KEY = "working-hours-calculator:v1"; +const STORAGE_KEY = 'whc-state-v1'; const DAY_LABELS = [ - "Week 1 - Monday", - "Week 1 - Tuesday", - "Week 1 - Wednesday", - "Week 1 - Thursday", - "Week 1 - Friday", - "Week 2 - Monday", - "Week 2 - Tuesday", - "Week 2 - Wednesday", - "Week 2 - Thursday", - "Week 2 - Friday", + 'Week 1 - Mon', + 'Week 1 - Tue', + 'Week 1 - Wed', + 'Week 1 - Thu', + 'Week 1 - Fri', + 'Week 2 - Mon', + 'Week 2 - Tue', + 'Week 2 - Wed', + 'Week 2 - Thu', + 'Week 2 - Fri' ]; -const defaultSettings = () => ({ - hoursPerWeek: 37.5, - targetFortnightHours: 75, - defaultBreakMinutes: 60, -}); - -const defaultDay = (breakMinutes) => ({ - start: "09:00", - end: "17:30", - breakMinutes, -}); - -const defaultPattern = (settings) => ({ - id: crypto.randomUUID(), - name: "Standard", - days: Array.from({ length: 10 }, () => defaultDay(settings.defaultBreakMinutes)), -}); - -let state = loadState(); +const settingsInputs = { + hoursPerWeek: document.getElementById('hours-per-week'), + targetFortnight: document.getElementById('target-fortnight'), + defaultBreak: document.getElementById('default-break'), + resetTarget: document.getElementById('reset-target') +}; +const heroStats = { + weekly: document.getElementById('stat-weekly'), + target: document.getElementById('stat-target'), + break: document.getElementById('stat-break'), + patterns: document.getElementById('stat-patterns') +}; +const addPatternButton = document.getElementById('add-pattern'); +const patternsContainer = document.getElementById('patterns'); +const comparisonBody = document.getElementById('comparison-body'); function loadState() { + const defaults = { + settings: { + hoursPerWeek: 37.5, + targetFortnightHours: 75, + defaultBreak: 60, + autoTarget: true + }, + patterns: [] + }; + try { - const raw = localStorage.getItem(STORAGE_KEY); - if (!raw) { - const settings = defaultSettings(); - return { settings, patterns: [defaultPattern(settings)] }; + const stored = localStorage.getItem(STORAGE_KEY); + if (!stored) { + return defaults; } - const parsed = JSON.parse(raw); - // Basic shape validation and defaults - const settings = { - ...defaultSettings(), - ...(parsed.settings || {}), + const parsed = JSON.parse(stored); + return { + settings: { + ...defaults.settings, + ...parsed.settings, + autoTarget: parsed.settings?.autoTarget ?? true + }, + patterns: parsed.patterns?.length ? parsed.patterns : [] }; - const patterns = Array.isArray(parsed.patterns) && parsed.patterns.length - ? parsed.patterns - : [defaultPattern(settings)]; - return { settings, patterns }; - } catch (err) { - console.error("Failed to load state, using defaults", err); - const settings = defaultSettings(); - return { settings, patterns: [defaultPattern(settings)] }; + } catch (error) { + console.warn('Falling back to defaults due to load error', error); + return defaults; } } -function persist() { +function saveState() { localStorage.setItem(STORAGE_KEY, JSON.stringify(state)); } -function parseTimeToMinutes(value) { - if (typeof value !== "string") return null; - const match = /^([01]?\d|2[0-3]):([0-5]\d)$/.exec(value.trim()); - if (!match) return null; - const hours = Number(match[1]); - const minutes = Number(match[2]); - return hours * 60 + minutes; +function minutesToDecimalHours(minutes) { + return Math.round((minutes / 60) * 100) / 100; } -function minutesToHoursDecimal(minutes) { - return (minutes / 60).toFixed(2); +function parseTimeToMinutes(timeString) { + if (!timeString || !/^\d{2}:\d{2}$/.test(timeString)) return null; + const [hours, minutes] = timeString.split(':').map(Number); + return hours * 60 + minutes; } -function calcDaily(day) { - const start = parseTimeToMinutes(day.start); - const end = parseTimeToMinutes(day.end); +function calculateDayHours(day) { + const startMinutes = parseTimeToMinutes(day.start); + const endMinutes = parseTimeToMinutes(day.end); const breakMinutes = Number(day.breakMinutes); - if (Number.isNaN(breakMinutes) || breakMinutes < 0) { - return { valid: false, hours: null, message: "Break must be non-negative" }; - } - // Treat a day with no times as a zero-hour (valid) day off - if (start === null && end === null) { - return { valid: true, hours: 0 }; - } - if (start === null || end === null) { - return { valid: false, hours: null, message: "Enter start and end time" }; - } - if (end <= start) { - return { valid: false, hours: null, message: "End must be after start" }; - } - const totalMinutes = end - start; - if (breakMinutes >= totalMinutes) { - return { valid: false, hours: null, message: "Break too long for shift" }; + if (startMinutes === null || endMinutes === null || Number.isNaN(breakMinutes)) { + return { valid: false, hours: null, message: 'Add start, end, and break' }; } - const workMinutes = totalMinutes - breakMinutes; - return { valid: true, hours: Number(minutesToHoursDecimal(workMinutes)) }; -} -function calcPattern(pattern, settings) { - const dayResults = pattern.days.map(calcDaily); + if (endMinutes <= startMinutes) { + return { valid: false, hours: null, message: 'End must be after start' }; + } - const week1 = dayResults.slice(0, 5).reduce((sum, d) => (d.valid ? sum + d.hours : sum), 0); - const week2 = dayResults.slice(5).reduce((sum, d) => (d.valid ? sum + d.hours : sum), 0); - const fortnight = week1 + week2; - const gap = Number((fortnight - settings.targetFortnightHours).toFixed(2)); - const hasInvalid = dayResults.some((d) => !d.valid); + const duration = endMinutes - startMinutes; + if (breakMinutes < 0 || breakMinutes >= duration) { + return { valid: false, hours: null, message: 'Break must be less than shift length' }; + } - return { dayResults, week1, week2, fortnight, gap, hasInvalid }; + const workMinutes = duration - breakMinutes; + return { + valid: true, + hours: minutesToDecimalHours(workMinutes) + }; } -function updateSettings(key, value) { - const newSettings = { ...state.settings, [key]: value }; +function calculatePatternTotals(pattern, settings) { + let week1 = 0; + let week2 = 0; + let hasInvalid = false; - // If hoursPerWeek changes and target matches prior derived, update target too - if (key === "hoursPerWeek") { - const priorDerived = Number((state.settings.hoursPerWeek * 2).toFixed(2)); - const currentTarget = Number(state.settings.targetFortnightHours); - const newDerived = Number((value * 2).toFixed(2)); - if (currentTarget === priorDerived) { - newSettings.targetFortnightHours = newDerived; + const dailyHours = pattern.days.map((day, index) => { + const result = calculateDayHours(day); + if (result.valid && typeof result.hours === 'number') { + if (index < 5) { + week1 += result.hours; + } else { + week2 += result.hours; + } + } else if (day.start || day.end || day.breakMinutes !== '') { + hasInvalid = true; } - } - - state = { ...state, settings: newSettings }; - persist(); - render(); -} - -function addPattern(prefill = true) { - const settings = state.settings; - const pattern = prefill - ? defaultPattern(settings) - : { - id: crypto.randomUUID(), - name: "New pattern", - days: Array.from({ length: 10 }, () => ({ - start: "", - end: "", - breakMinutes: settings.defaultBreakMinutes, - })), - }; + return result; + }); - state = { ...state, patterns: [...state.patterns, pattern] }; - persist(); - render(); - showToast("Pattern added"); -} + const fortnight = Math.round((week1 + week2) * 100) / 100; + const gap = Math.round((fortnight - Number(settings.targetFortnightHours || 0)) * 100) / 100; -function duplicatePattern(id) { - const existing = state.patterns.find((p) => p.id === id); - if (!existing) return; - const copy = { - ...existing, - id: crypto.randomUUID(), - name: `${existing.name} (copy)`, - days: existing.days.map((d) => ({ ...d })), + return { + week1: Math.round(week1 * 100) / 100, + week2: Math.round(week2 * 100) / 100, + fortnight, + gap, + dailyHours, + hasInvalid }; - state = { ...state, patterns: [...state.patterns, copy] }; - persist(); - render(); - showToast("Pattern duplicated"); } -function deletePattern(id) { - const pattern = state.patterns.find((p) => p.id === id); - if (!pattern) return; - if (!confirm(`Delete pattern "${pattern.name}"?`)) return; - state = { ...state, patterns: state.patterns.filter((p) => p.id !== id) }; - if (state.patterns.length === 0) { - state.patterns.push(defaultPattern(state.settings)); - } - persist(); - render(); - showToast("Pattern deleted"); -} +function createDefaultPattern(name) { + const defaultDay = () => ({ + start: '09:00', + end: '17:30', + breakMinutes: state.settings.defaultBreak + }); -function renamePattern(id, name) { - state = { - ...state, - patterns: state.patterns.map((p) => (p.id === id ? { ...p, name } : p)), + return { + id: crypto.randomUUID ? crypto.randomUUID() : `pattern-${Date.now()}-${Math.random()}`, + name, + days: Array.from({ length: 10 }, defaultDay) }; - persist(); - render(); } -function updateDayField(patternId, dayIndex, field, value) { - state = { - ...state, - patterns: state.patterns.map((p) => - p.id === patternId - ? { - ...p, - days: p.days.map((d, idx) => - idx === dayIndex ? { ...d, [field]: field === "breakMinutes" ? Number(value) : value } : d - ), - } - : p - ), - }; - persist(); - render(); +const state = loadState(); +if (state.patterns.length === 0) { + state.patterns.push(createDefaultPattern('Pattern 1')); + saveState(); } function renderSettings() { - const { hoursPerWeek, targetFortnightHours, defaultBreakMinutes } = state.settings; - const hoursInput = document.querySelector("#hoursPerWeek"); - const targetInput = document.querySelector("#targetFortnightHours"); - const breakInput = document.querySelector("#defaultBreakMinutes"); - const errorsEl = document.querySelector("#settings-errors"); - - hoursInput.value = hoursPerWeek; - targetInput.value = targetFortnightHours; - breakInput.value = defaultBreakMinutes; - - const errors = []; - if (!(hoursPerWeek > 0)) errors.push("Hours per week must be greater than 0"); - if (!(targetFortnightHours > 0)) errors.push("Target fortnight hours must be greater than 0"); - if (!(defaultBreakMinutes >= 0)) errors.push("Default break must be non-negative"); - errorsEl.textContent = errors.join(" • "); + settingsInputs.hoursPerWeek.value = state.settings.hoursPerWeek; + settingsInputs.targetFortnight.value = state.settings.targetFortnightHours; + settingsInputs.defaultBreak.value = state.settings.defaultBreak; + updateHeroStats(); } -function renderPatterns() { - const container = document.querySelector("#patterns-container"); - container.innerHTML = ""; - - state.patterns.forEach((pattern) => { - const { dayResults, week1, week2, fortnight, gap, hasInvalid } = calcPattern( - pattern, - state.settings - ); - - const card = document.createElement("article"); - card.className = "pattern-card"; - card.dataset.patternId = pattern.id; +function updateTargetFromWeekly() { + state.settings.autoTarget = true; + state.settings.targetFortnightHours = Math.round((Number(state.settings.hoursPerWeek) * 2) * 100) / 100; +} - const header = document.createElement("div"); - header.className = "pattern-header"; - - header.innerHTML = ` -
- - ${hasInvalid ? 'Pattern has invalid days' : ""} -
-
- - -
- `; +function renderPatterns() { + patternsContainer.innerHTML = ''; + updateHeroStats(); + + state.patterns.forEach((pattern, patternIndex) => { + const metrics = calculatePatternTotals(pattern, state.settings); + const patternTemplate = document.getElementById('pattern-template'); + const node = patternTemplate.content.cloneNode(true); + const card = node.querySelector('.pattern-card'); + + const nameInput = node.querySelector('.pattern-name'); + nameInput.value = pattern.name; + nameInput.addEventListener('input', (event) => { + state.patterns[patternIndex].name = event.target.value; + saveAndRender(); + }); - const tableWrapper = document.createElement("div"); - tableWrapper.className = "table-wrapper"; - - const table = document.createElement("table"); - table.className = "day-grid"; - const tbody = document.createElement("tbody"); - - // Header - table.innerHTML = ` - - - Day - Start - End - Break (min) - Hours - - - `; + const duplicateBtn = node.querySelector('.duplicate'); + duplicateBtn.addEventListener('click', () => { + const copy = JSON.parse(JSON.stringify(pattern)); + copy.id = crypto.randomUUID ? crypto.randomUUID() : `pattern-${Date.now()}-${Math.random()}`; + copy.name = `${pattern.name} (copy)`; + state.patterns.splice(patternIndex + 1, 0, copy); + saveAndRender(); + }); - pattern.days.forEach((day, idx) => { - const result = dayResults[idx]; - const row = document.createElement("tr"); - if (!result.valid) row.classList.add("invalid-row"); - - row.innerHTML = ` - ${DAY_LABELS[idx]} - - - - ${result.valid ? result.hours.toFixed(2) : "—"} - `; - - if (!result.valid && result.message) { - const msgRow = document.createElement("tr"); - msgRow.className = "invalid-row"; - msgRow.innerHTML = `${result.message}`; - tbody.appendChild(row); - tbody.appendChild(msgRow); - } else { - tbody.appendChild(row); - } + const deleteBtn = node.querySelector('.delete'); + deleteBtn.disabled = state.patterns.length === 1; + deleteBtn.addEventListener('click', () => { + if (state.patterns.length === 1) return; + state.patterns.splice(patternIndex, 1); + saveAndRender(); }); - const summaryRow = document.createElement("tr"); - summaryRow.className = "summary-row"; - summaryRow.innerHTML = ` - Totals - Week 1: ${week1.toFixed(2)} - Week 2: ${week2.toFixed(2)} - Fortnight: ${fortnight.toFixed(2)} - ${formatGap(gap)} - `; - tbody.appendChild(summaryRow); + const dayRowsContainer = node.querySelector('.day-rows'); + const dayRowTemplate = document.getElementById('day-row-template'); + + pattern.days.forEach((day, dayIndex) => { + const dayNode = dayRowTemplate.content.cloneNode(true); + const dayLabel = dayNode.querySelector('.day-label'); + dayLabel.textContent = DAY_LABELS[dayIndex]; + + const startInput = dayNode.querySelector('.day-start'); + const endInput = dayNode.querySelector('.day-end'); + const breakInput = dayNode.querySelector('.day-break'); + const hoursCell = dayNode.querySelector('.day-hours'); + + startInput.value = day.start; + endInput.value = day.end; + breakInput.value = day.breakMinutes; + + const renderDaily = () => { + const result = metrics.dailyHours[dayIndex]; + if (result.valid) { + hoursCell.textContent = `${result.hours.toFixed(2)} h`; + hoursCell.classList.remove('invalid'); + } else { + hoursCell.textContent = result.message || 'Invalid'; + hoursCell.classList.add('invalid'); + } + }; - table.appendChild(tbody); - tableWrapper.appendChild(table); + startInput.addEventListener('change', (event) => { + pattern.days[dayIndex].start = event.target.value; + saveAndRender(); + }); - card.appendChild(header); - const body = document.createElement("div"); - body.className = "panel-body"; - body.appendChild(tableWrapper); - card.appendChild(body); - container.appendChild(card); - }); + endInput.addEventListener('change', (event) => { + pattern.days[dayIndex].end = event.target.value; + saveAndRender(); + }); - bindPatternEvents(); -} - -function formatGap(gap) { - const cls = gap === 0 ? "gap-zero" : gap > 0 ? "gap-positive" : "gap-negative"; - const sign = gap > 0 ? "+" : ""; - return `${sign}${gap.toFixed(2)}`; -} + breakInput.addEventListener('change', (event) => { + const value = Number(event.target.value); + pattern.days[dayIndex].breakMinutes = Number.isNaN(value) ? '' : value; + saveAndRender(); + }); -function bindPatternEvents() { - document.querySelectorAll("[data-role='pattern-name']").forEach((input) => { - input.addEventListener("input", (e) => { - const patternId = e.target.closest(".pattern-card").dataset.patternId; - renamePattern(patternId, e.target.value); + renderDaily(); + dayRowsContainer.appendChild(dayNode); }); - }); - document.querySelectorAll("[data-role='duplicate']").forEach((btn) => { - btn.addEventListener("click", (e) => { - const patternId = e.target.closest(".pattern-card").dataset.patternId; - duplicatePattern(patternId); - }); - }); + const summary = node.querySelector('.summary-values'); + summary.innerHTML = ` +
Week 1: ${metrics.week1.toFixed(2)} hrs
+
Week 2: ${metrics.week2.toFixed(2)} hrs
+
Fortnight: ${metrics.fortnight.toFixed(2)} hrs
+
Gap: ${formatGap(metrics.gap)}
+ `; - document.querySelectorAll("[data-role='delete']").forEach((btn) => { - btn.addEventListener("click", (e) => { - const patternId = e.target.closest(".pattern-card").dataset.patternId; - deletePattern(patternId); - }); - }); + const statusText = node.querySelector('.status-text'); + const badges = node.querySelector('.badges'); + badges.innerHTML = ''; + + if (metrics.hasInvalid) { + statusText.textContent = 'Contains invalid days (excluded from totals).'; + const badge = document.createElement('span'); + badge.className = 'badge info'; + badge.textContent = 'Fix entries to include them in totals'; + badges.appendChild(badge); + } else { + statusText.textContent = 'All days valid and counted in totals.'; + } - document.querySelectorAll(".pattern-card input[type='time']").forEach((input) => { - input.addEventListener("change", (e) => { - const patternId = e.target.closest(".pattern-card").dataset.patternId; - const dayIndex = Number(e.target.dataset.idx); - const field = e.target.dataset.role; - updateDayField(patternId, dayIndex, field, e.target.value); - }); - }); + const gapBadge = document.createElement('span'); + gapBadge.classList.add('badge', classifyGap(metrics.gap)); + gapBadge.textContent = `Gap: ${formatGap(metrics.gap)}`; + badges.appendChild(gapBadge); - document.querySelectorAll(".pattern-card input[type='number']").forEach((input) => { - input.addEventListener("change", (e) => { - const patternId = e.target.closest(".pattern-card").dataset.patternId; - const dayIndex = Number(e.target.dataset.idx); - updateDayField(patternId, dayIndex, "breakMinutes", Number(e.target.value)); - }); + card.dataset.patternId = pattern.id; + patternsContainer.appendChild(node); }); } function renderComparison() { - const tbody = document.querySelector("#comparison-table tbody"); - tbody.innerHTML = ""; - - const rows = state.patterns.map((p) => { - const { week1, week2, fortnight, gap, hasInvalid } = calcPattern(p, state.settings); - return { id: p.id, name: p.name, week1, week2, fortnight, gap, hasInvalid }; - }); + comparisonBody.innerHTML = ''; + const totals = state.patterns.map((pattern) => calculatePatternTotals(pattern, state.settings)); + const bestIndex = totals.reduce((best, current, idx) => { + if (best === -1) return idx; + const currentGap = Math.abs(current.gap); + const bestGap = Math.abs(totals[best].gap); + return currentGap < bestGap ? idx : best; + }, totals.length ? 0 : -1); + + state.patterns.forEach((pattern, idx) => { + const metrics = totals[idx]; + const row = document.createElement('tr'); + if (idx === bestIndex) { + row.classList.add('best-match'); + } - const validRows = rows.filter((r) => !r.hasInvalid); - let highlightId = null; - if (validRows.length) { - highlightId = validRows.reduce((best, r) => { - const bestAbs = Math.abs(rows.find((x) => x.id === best)?.gap ?? Infinity); - const currentAbs = Math.abs(r.gap); - return currentAbs < bestAbs ? r.id : best; - }, validRows[0].id); - } + const cells = [ + pattern.name, + metrics.week1.toFixed(2), + metrics.week2.toFixed(2), + metrics.fortnight.toFixed(2), + formatGap(metrics.gap) + ]; + + cells.forEach((value, cellIndex) => { + const td = document.createElement('td'); + td.textContent = value; + if (cellIndex === 4) { + td.classList.add(classifyGap(metrics.gap)); + } + row.appendChild(td); + }); - rows.forEach((r) => { - const tr = document.createElement("tr"); - if (r.id === highlightId) tr.classList.add("highlight"); - tr.innerHTML = ` - ${r.name} - ${r.hasInvalid ? "—" : r.week1.toFixed(2)} - ${r.hasInvalid ? "—" : r.week2.toFixed(2)} - ${r.hasInvalid ? "—" : r.fortnight.toFixed(2)} - ${r.hasInvalid ? 'Invalid' : formatGap(r.gap)} - `; - tbody.appendChild(tr); + comparisonBody.appendChild(row); }); } -function showToast(message) { - const toast = document.querySelector("#toast"); - toast.textContent = message; - toast.classList.add("toast-visible"); - clearTimeout(showToast._timer); - showToast._timer = setTimeout(() => toast.classList.remove("toast-visible"), 1800); +function classifyGap(gap) { + if (gap === 0) return 'gap-zero'; + return gap > 0 ? 'gap-positive' : 'gap-negative'; } -function bindSettingsEvents() { - document.querySelector("#hoursPerWeek").addEventListener("change", (e) => { - updateSettings("hoursPerWeek", Number(e.target.value)); - }); - document.querySelector("#targetFortnightHours").addEventListener("change", (e) => { - updateSettings("targetFortnightHours", Number(e.target.value)); - }); - document.querySelector("#defaultBreakMinutes").addEventListener("change", (e) => { - updateSettings("defaultBreakMinutes", Number(e.target.value)); - }); +function formatGap(gap) { + if (gap === 0) return '0.00 hrs'; + return `${gap > 0 ? '+' : ''}${gap.toFixed(2)} hrs`; } -function bindGlobalActions() { - document.querySelector("#addPatternBtn").addEventListener("click", () => addPattern(true)); +function updateHeroStats() { + if (heroStats.weekly) { + heroStats.weekly.textContent = `${Number(state.settings.hoursPerWeek || 0).toFixed(2)} hrs`; + } + if (heroStats.target) { + heroStats.target.textContent = `${Number(state.settings.targetFortnightHours || 0).toFixed(2)} hrs`; + } + if (heroStats.break) { + heroStats.break.textContent = `${Number(state.settings.defaultBreak || 0)} min`; + } + if (heroStats.patterns) { + heroStats.patterns.textContent = state.patterns.length.toString(); + } } -function render() { +function saveAndRender() { + saveState(); renderSettings(); renderPatterns(); renderComparison(); } -function runInlineChecks() { - // 37.5h per week pattern totals - const pattern = defaultPattern(defaultSettings()); - const res = calcPattern(pattern, defaultSettings()); - if (res.fortnight !== 75) { - console.warn("Check failed: expected 75 hours fortnight, got", res.fortnight); - } +function bindSettingsListeners() { + settingsInputs.hoursPerWeek.addEventListener('change', (event) => { + const value = Number(event.target.value); + state.settings.hoursPerWeek = Number.isNaN(value) ? 0 : value; + if (state.settings.autoTarget) { + updateTargetFromWeekly(); + } + saveAndRender(); + }); - // Week 2 Friday off scenario - const custom = defaultPattern(defaultSettings()); - custom.days[9] = { ...custom.days[9], start: "", end: "", breakMinutes: 60 }; - const res2 = calcPattern(custom, defaultSettings()); - if (res2.hasInvalid || res2.fortnight !== 67.5) { - console.warn("Check: Week2 Friday off expected 67.5 hours, got", res2.fortnight); - } + settingsInputs.targetFortnight.addEventListener('change', (event) => { + const value = Number(event.target.value); + state.settings.targetFortnightHours = Number.isNaN(value) ? 0 : value; + state.settings.autoTarget = false; + saveAndRender(); + }); + + settingsInputs.defaultBreak.addEventListener('change', (event) => { + const value = Number(event.target.value); + state.settings.defaultBreak = Number.isNaN(value) ? 0 : value; + saveAndRender(); + }); + + settingsInputs.resetTarget.addEventListener('click', () => { + updateTargetFromWeekly(); + saveAndRender(); + }); } -function init() { - bindSettingsEvents(); - bindGlobalActions(); - render(); - runInlineChecks(); +function bindGlobalActions() { + addPatternButton.addEventListener('click', () => { + const newIndex = state.patterns.length + 1; + state.patterns.push(createDefaultPattern(`Pattern ${newIndex}`)); + saveAndRender(); + }); } -init(); +bindSettingsListeners(); +bindGlobalActions(); +saveAndRender(); diff --git a/working-hours-calculator/index.html b/working-hours-calculator/index.html index 417fc3b..17a6cec 100644 --- a/working-hours-calculator/index.html +++ b/working-hours-calculator/index.html @@ -4,79 +4,161 @@ Working Hours Calculator - + + + + -
-
-

Working Hours Calculator

-

Design and compare 2-week working patterns

+
+
+

Working Hours Calculator

+

Plan and compare your two-week rota

+

Set your contract once, add as many patterns as you need, and instantly see how close each option lands to your target fortnight.

+
    +
  • Separate week totals with automatic fortnight calculations.
  • +
  • Inline validation for start/end/break to mirror spreadsheet rules.
  • +
  • Local storage keeps your settings and patterns between visits.
  • +
+
+
+
+ Weekly hours + 37.5 hrs +
+
+ Fortnight target + 75 hrs +
+
+ Default break + 60 min +
+
+ Patterns in view + 1 +
-
-
-
-

Contract & Settings

+
+
+
+
+

Contract

+

Global settings

+

Defaults used by every pattern. Update weekly hours or breaks and we'll keep your targets aligned.

+
-
+
+
+ Target control + + Target stays in sync while auto mode is on. +
-
-
-
+
+
-

Patterns

-

Edit start/end/break per day. Totals update instantly.

+

Patterns

+

Edit working patterns

+

Each pattern spans two weeks (Mon–Fri). Adjust start, end, and breaks to see updated totals and gaps.

-
- +
+
-
+
-
-
-

Comparison

-

All patterns at a glance

+
+
+
+

Comparison

+

Pattern leaderboard

+

See which pattern is closest to your contract target. Best match is highlighted.

+
-
-
- +
+
+ + + + + + + + + + +
NameWeek 1 (hrs)Week 2 (hrs)Fortnight (hrs)Gap vs target
+
+
+
+ + -
+ - + diff --git a/working-hours-calculator/styles.css b/working-hours-calculator/styles.css index 9ffba45..5c8cdd0 100644 --- a/working-hours-calculator/styles.css +++ b/working-hours-calculator/styles.css @@ -1,286 +1,415 @@ :root { + color-scheme: light; --bg: #f7f7fb; --panel: #ffffff; - --border: #e0e0ea; - --text: #1f2430; - --muted: #5c6475; - --primary: #2d6cdf; - --primary-hover: #2558b8; - --positive: #1f9d5c; - --negative: #d64545; - --zero: #6b7280; - --invalid: #f9e1e1; - --shadow: 0 8px 20px rgba(0, 0, 0, 0.06); -} - -*, -*::before, -*::after { + --border: #e3e6ee; + --text: #0f172a; + --muted: #4b5563; + --accent: #2563eb; + --accent-soft: #e0e7ff; + --danger: #ef4444; + --positive: #16a34a; + --shadow: 0 10px 30px rgba(15, 23, 42, 0.08); + --radius: 14px; + font-family: 'Inter', system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; +} + +* { box-sizing: border-box; } body { margin: 0; - font-family: "Inter", system-ui, -apple-system, sans-serif; + background: radial-gradient(circle at 10% 20%, #eef2ff 0, rgba(238, 242, 255, 0) 26%), + radial-gradient(circle at 90% 10%, #e0f2fe 0, rgba(224, 242, 254, 0) 22%), + linear-gradient(180deg, #f6f7fb 0%, #ffffff 45%); color: var(--text); - background: var(--bg); - line-height: 1.5; } -.container { - width: min(1100px, 100%); - margin: 0 auto; - padding: 0 1rem; +h1, h2, h3, h4, h5, h6 { + margin: 0; } -.app-header { - background: #0f172a; - color: white; - padding: 1.5rem 0; - box-shadow: var(--shadow); +p { + margin: 0; } -.app-header h1 { - margin: 0 0 0.25rem; +.hero { + max-width: 1200px; + margin: 32px auto 16px auto; + padding: 28px; + display: grid; + grid-template-columns: 7fr 5fr; + gap: 22px; + align-items: center; } -.subtitle { +.hero__text h1 { + font-size: 2.6rem; + line-height: 1.18; + margin-bottom: 10px; +} + +.hero__text .lede { color: var(--muted); - margin: 0.1rem 0 0; + font-size: 1.05rem; + margin-bottom: 14px; } -.panel { +.hero__list { + margin: 0; + padding-left: 18px; + color: var(--muted); + display: grid; + gap: 8px; +} + +.hero__stats { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 12px; +} + +.stat { background: var(--panel); border: 1px solid var(--border); - border-radius: 10px; - margin: 1.25rem 0; + border-radius: var(--radius); + padding: 14px; box-shadow: var(--shadow); } -.panel-header { - padding: 1.1rem 1.25rem 0.75rem; - border-bottom: 1px solid var(--border); +.stat .label { + display: block; + color: var(--muted); + font-size: 0.85rem; } -.panel-body { - padding: 1rem 1.25rem 1.25rem; +.stat .value { + font-weight: 800; + font-size: 1.05rem; } -.panel-footer { - padding: 0 1.25rem 1rem; - color: var(--negative); - min-height: 1.25rem; +.eyebrow { + text-transform: uppercase; + letter-spacing: 0.08em; + font-size: 0.8rem; + color: var(--accent); + font-weight: 700; } -.panel-header.actions { - display: flex; +.lede { + font-size: 1rem; +} + +.pill { + display: inline-flex; align-items: center; + background: var(--accent-soft); + color: var(--accent); + padding: 6px 10px; + border-radius: 999px; + font-weight: 600; + font-size: 0.85rem; +} + +.layout { + max-width: 1200px; + margin: 0 auto 64px auto; + padding: 0 16px 16px 16px; + display: flex; + flex-direction: column; + gap: 18px; +} + +.card { + background: var(--panel); + border-radius: var(--radius); + border: 1px solid var(--border); + box-shadow: var(--shadow); + padding: 20px; +} + +.card__header { + display: flex; + align-items: flex-start; justify-content: space-between; - gap: 1rem; + gap: 12px; + margin-bottom: 12px; +} + +.actions { + display: flex; + gap: 8px; } .settings-grid { display: grid; - grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); - gap: 1rem; + grid-template-columns: repeat(auto-fit, minmax(240px, 1fr)); + gap: 16px; } .field { display: flex; flex-direction: column; - gap: 0.35rem; - font-size: 0.95rem; + gap: 6px; + background: #f9fafb; + border: 1px solid var(--border); + padding: 12px; + border-radius: 10px; } -.field input, -.field select { - padding: 0.6rem 0.7rem; - border-radius: 8px; +.field span { + font-weight: 600; +} + +.field input, .field button { + font-size: 1rem; + padding: 10px 12px; + border-radius: 10px; border: 1px solid var(--border); - background: #fbfbfe; - font-size: 0.95rem; + background: #fff; color: var(--text); } -.field input:focus { - outline: 2px solid rgba(45, 108, 223, 0.25); +.field input:focus, .field button:focus { + outline: 2px solid var(--accent); + border-color: var(--accent); } -.hint { +.field .helper { color: var(--muted); + font-size: 0.85rem; } -.btn { - border: none; - border-radius: 8px; - padding: 0.65rem 0.9rem; - font-weight: 600; - cursor: pointer; - transition: background 120ms ease, transform 120ms ease; -} - -.btn.primary { - background: var(--primary); - color: white; +.field.action { + justify-content: center; } -.btn.primary:hover { - background: var(--primary-hover); +.primary { + background: linear-gradient(135deg, #2563eb, #1d4ed8); + color: #fff; + border: none; + padding: 10px 14px; + border-radius: 10px; + font-weight: 700; + cursor: pointer; + box-shadow: 0 10px 20px rgba(37, 99, 235, 0.2); } -.btn.ghost { - background: transparent; - border: 1px solid var(--border); +.ghost { + background: #f8fafc; color: var(--text); + border: 1px solid var(--border); + padding: 9px 12px; + border-radius: 10px; + font-weight: 600; + cursor: pointer; } -.btn.small { - padding: 0.4rem 0.6rem; - font-size: 0.9rem; +.ghost.danger { + color: var(--danger); + border-color: rgba(239, 68, 68, 0.3); } -.button-group { - display: flex; - align-items: center; - gap: 0.5rem; +button:disabled { + opacity: 0.5; + cursor: not-allowed; } -.pattern-card { - border-top: 1px solid var(--border); +.muted { + color: var(--muted); } -.pattern-card:first-child { - border-top: none; +.pattern-card { + border: 1px solid var(--border); + border-radius: var(--radius); + padding: 14px; + margin-bottom: 12px; + background: #fdfdff; + box-shadow: var(--shadow); } -.pattern-header { +.pattern-card__header { display: flex; align-items: center; justify-content: space-between; - padding: 1rem 1.25rem; - gap: 1rem; + gap: 8px; + margin-bottom: 10px; } .pattern-name { + flex: 1; + font-size: 1.1rem; + padding: 10px 12px; + border-radius: 10px; + border: 1px solid var(--border); + font-weight: 700; +} + +.pattern-actions { display: flex; - flex-direction: column; - gap: 0.35rem; + gap: 6px; } -.pattern-name input { - width: 220px; - max-width: 100%; +.table-responsive { + overflow-x: auto; } -.day-grid { +.day-table { width: 100%; border-collapse: collapse; } -.day-grid th, -.day-grid td { - padding: 0.55rem; +.day-table th, +.day-table td { text-align: left; + padding: 10px 8px; border-bottom: 1px solid var(--border); } -.day-grid th { - background: #f1f3f8; - font-weight: 600; - font-size: 0.95rem; +.day-table thead { + background: #f3f4f6; } -.day-grid td input { +.day-table input { width: 100%; - min-width: 90px; + padding: 8px; + border-radius: 8px; + border: 1px solid var(--border); } -.day-grid .hours-cell { - font-variant-numeric: tabular-nums; +.day-table input:focus { + outline: 2px solid var(--accent); + border-color: var(--accent); } -.invalid-row { - background: var(--invalid); +.day-hours.invalid { + color: var(--danger); + font-weight: 700; } -.summary-row { +.summary-row th, +.summary-row td { + background: #eef2ff; font-weight: 700; - background: #f8fafc; } -.gap-positive { +.pattern-status { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + padding: 10px 0 0 0; + flex-wrap: wrap; +} + +.status-text { + color: var(--muted); + font-weight: 600; +} + +.badge { + display: inline-flex; + align-items: center; + gap: 6px; + padding: 8px 10px; + border-radius: 12px; + font-weight: 700; +} + +.badge.gap-positive { + background: rgba(22, 163, 74, 0.12); color: var(--positive); } -.gap-negative { - color: var(--negative); +.badge.gap-negative { + background: rgba(239, 68, 68, 0.12); + color: var(--danger); +} + +.badge.gap-zero { + background: rgba(79, 70, 229, 0.12); + color: #4f46e5; } -.gap-zero { - color: var(--zero); +.badge.info { + background: #f8fafc; + color: var(--muted); } -.table-wrapper { +.comparison-table__wrapper { overflow-x: auto; } -#comparison-table { +.comparison-table { width: 100%; border-collapse: collapse; } -#comparison-table th, -#comparison-table td { - padding: 0.65rem; +.comparison-table th, +.comparison-table td { + padding: 12px 10px; border-bottom: 1px solid var(--border); text-align: left; } -#comparison-table tbody tr.highlight { - background: #e7f0ff; +.comparison-table thead { + background: #f8fafc; } -.tag { - display: inline-flex; - align-items: center; - gap: 0.35rem; - padding: 0.25rem 0.5rem; - border-radius: 6px; - font-size: 0.85rem; - background: #eef2ff; - color: #3730a3; +.comparison-table tr.best-match { + background: rgba(37, 99, 235, 0.08); + font-weight: 700; } -.error-text { - color: var(--negative); - font-size: 0.9rem; +.comparison-table .gap-positive { + color: var(--positive); } -.toast-visible { - position: fixed; - bottom: 1rem; - right: 1rem; - background: #111827; - color: white; - padding: 0.75rem 1rem; - border-radius: 10px; - box-shadow: var(--shadow); - opacity: 0.95; +.comparison-table .gap-negative { + color: var(--danger); } -@media (max-width: 720px) { - .panel-header.actions { - flex-direction: column; - align-items: flex-start; +.comparison-table .gap-zero { + color: #111827; +} + +.helper-row { + display: flex; + gap: 8px; + align-items: center; + color: var(--muted); + margin-top: 4px; +} + +@media (max-width: 900px) { + .hero { + grid-template-columns: 1fr; + } + + .hero__stats { + grid-template-columns: repeat(2, minmax(0, 1fr)); } - .pattern-header { + .pattern-card__header { flex-direction: column; align-items: flex-start; } - .day-grid th:nth-child(2), - .day-grid td:nth-child(2) { - width: 30%; + .pattern-actions { + width: 100%; + justify-content: flex-start; + } +} + +@media (max-width: 640px) { + .hero__stats { + grid-template-columns: 1fr; + } +} + +@media (max-width: 600px) { + .card__header { + flex-direction: column; } }