diff --git a/api/timers.php b/api/timers.php
new file mode 100644
index 0000000..c6a7cc9
--- /dev/null
+++ b/api/timers.php
@@ -0,0 +1,41 @@
+query("SELECT * FROM saved_timers ORDER BY created_at DESC");
+ $timers = $stmt->fetchAll();
+ echo json_encode(['success' => true, 'data' => $timers]);
+ }
+ elseif ($method === 'POST') {
+ $data = json_decode(file_get_contents('php://input'), true);
+ if (empty($data['name']) || empty($data['config'])) {
+ throw new Exception("Missing name or config");
+ }
+
+ $stmt = $pdo->prepare("INSERT INTO saved_timers (name, config) VALUES (:name, :config)");
+ $stmt->execute([
+ ':name' => $data['name'],
+ ':config' => json_encode($data['config'])
+ ]);
+ echo json_encode(['success' => true, 'id' => $pdo->lastInsertId()]);
+ }
+ elseif ($method === 'DELETE') {
+ $id = $_GET['id'] ?? null;
+ if (!$id) {
+ throw new Exception("Missing id");
+ }
+
+ $stmt = $pdo->prepare("DELETE FROM saved_timers WHERE id = :id");
+ $stmt->execute([':id' => $id]);
+ echo json_encode(['success' => true]);
+ }
+} catch (Exception $e) {
+ http_response_code(400);
+ echo json_encode(['success' => false, 'error' => $e->getMessage()]);
+}
diff --git a/assets/css/custom.css b/assets/css/custom.css
index 119a287..afeabe1 100644
--- a/assets/css/custom.css
+++ b/assets/css/custom.css
@@ -168,6 +168,16 @@ body {
padding: 0.4rem 0.75rem;
}
+.form-control-compact {
+ padding: 0.25rem 0.5rem;
+ font-size: 0.75rem;
+}
+
+.btn-compact {
+ padding: 0.2rem 0.4rem;
+ font-size: 0.65rem;
+}
+
#session-title {
background-color: transparent;
border-width: 0 0 2px 0;
@@ -218,19 +228,6 @@ body {
padding: 0.5rem 1rem !important;
}
-.activity-row.rest-activity .form-control {
- font-size: 0.8rem;
-}
-
-.activity-row.rest-activity .btn {
- font-size: 0.65rem;
- padding: 0.2rem 0.4rem;
-}
-
-.activity-row.rest-activity .fs-tiny {
- font-size: 0.6rem;
-}
-
/* Options Dropdown */
.dropdown-menu {
background-color: var(--surface-color);
@@ -261,6 +258,7 @@ body {
/* Alerts / Finishes */
.timer-finish {
+ color: var(--danger-color) !important;
animation: flash 1s infinite;
}
@@ -270,6 +268,16 @@ body {
100% { opacity: 1; }
}
+@keyframes blink-red {
+ 0% { color: inherit; }
+ 50% { color: var(--danger-color); }
+ 100% { color: inherit; }
+}
+
+.timer-alert {
+ animation: blink-red 0.5s infinite;
+}
+
/* Settings Switches */
.form-check-input:checked {
background-color: var(--accent-color);
@@ -280,19 +288,19 @@ body {
.timer-workspace-container {
display: flex;
flex-wrap: wrap;
- gap: 2rem;
+ gap: 3rem;
justify-content: center;
+ align-items: flex-start;
}
.timer-main-column {
- flex: 1;
+ flex: 0 1 600px;
min-width: 320px;
- max-width: 800px;
order: 2; /* Main on right on desktop */
}
.timer-side-column {
- width: 380px;
+ width: 450px;
order: 1; /* History on left on desktop */
}
@@ -304,6 +312,8 @@ body {
order: 2; /* Move history back to bottom on mobile */
}
.timer-main-column {
+ flex: 1;
+ max-width: 800px;
order: 1; /* Main timer on top on mobile */
}
}
@@ -342,4 +352,9 @@ body.dark-mode .font-tabular {
}
body.dark-mode .text-muted {
color: var(--text-secondary) !important;
+}
+
+/* Saved Timers Hover Effect */
+#saved-timers-list .card-precise:hover {
+ border-color: var(--success-color);
}
\ No newline at end of file
diff --git a/assets/js/main.js b/assets/js/main.js
index 3fcfe53..28304da 100644
--- a/assets/js/main.js
+++ b/assets/js/main.js
@@ -29,11 +29,19 @@ const app = {
// Custom State
customActivities: [
- { name: 'Activity 1', duration: 60000, isRest: false },
- { name: 'Rest', duration: 30000, isRest: true }
+ { name: 'Activity 1', duration: 60000, isRest: false }
],
currentActivityIndex: 0,
- draggedItemIndex: null,
+
+ // Session titles stored per mode
+ sessionTitles: {
+ 'time-watch': '',
+ 'countdown': '',
+ 'stopwatch': '',
+ 'lap': '',
+ 'relay': '',
+ 'custom': ''
+ },
// Audio Helpers
audioCtx: null,
@@ -42,11 +50,11 @@ const app = {
// CONFIG
modes: {
'time-watch': { title: 'Time-watch', allowPause: true, hasLaps: false, canToggleCountdown: true },
- 'countdown': { title: 'Countdown Timer', allowPause: true, hasLaps: false, forceCountdown: true },
- 'stopwatch': { title: 'Stopwatch', allowPause: false, hasLaps: false, canToggleCountdown: false },
+ 'countdown': { title: 'Countdown Timer', allowPause: true, hasLaps: false, forceCountdown: true, canSave: true },
+ 'stopwatch': { title: 'Stopwatch', allowPause: false, hasLaps: false, canToggleCountdown: false, canSave: true },
'lap': { title: 'Lap Timer', allowPause: false, hasLaps: true, canToggleCountdown: false },
'relay': { title: 'Relay Timer', allowPause: true, hasRelay: true, canToggleCountdown: false },
- 'custom': { title: 'Custom Timer', allowPause: true, isCustom: true, canToggleCountdown: true }
+ 'custom': { title: 'Custom Timer', allowPause: true, isCustom: true, canToggleCountdown: true, canSave: true }
},
// ELEMENTS
@@ -68,11 +76,15 @@ const app = {
btnReset: document.getElementById('btn-reset'),
btnLap: document.getElementById('btn-lap'),
btnNext: document.getElementById('btn-next'),
+ btnSaveMain: document.getElementById('btn-save-main'),
+ btnSaveBuilder: document.getElementById('btn-save-session-builder'),
formatSelect: document.getElementById('format-select'),
listTitle: document.getElementById('list-title'),
listHead: document.getElementById('list-head'),
listBody: document.getElementById('list-body'),
+ lapSort: document.getElementById('lap-sort'),
+ lapResultsContainer: document.getElementById('lap-results-container'),
countdownInputs: document.getElementById('countdown-inputs'),
inputH: document.getElementById('input-h'),
@@ -84,7 +96,10 @@ const app = {
customBuilder: document.getElementById('custom-builder'),
activityList: document.getElementById('activity-list'),
- btnAddActivity: document.getElementById('btn-add-activity'),
+ customTotalDuration: document.getElementById('custom-total-duration'),
+ savedTimersContainer: document.getElementById('saved-timers-container'),
+ savedTimersDropdown: document.getElementById('saved-timers-dropdown'),
+ btnDeleteSaved: document.getElementById('btn-delete-saved'),
// Settings / Options
optCountdownContainer: document.getElementById('opt-countdown-container'),
@@ -111,6 +126,7 @@ const app = {
this.bindEvents();
this.renderCustomBuilder();
this.resetTimer();
+ this.loadSavedTimers();
},
initDarkMode() {
@@ -137,7 +153,8 @@ const app = {
this.el.btnReset.addEventListener('click', () => this.resetTimer());
this.el.btnLap.addEventListener('click', () => this.recordLap());
this.el.btnNext.addEventListener('click', () => this.recordRelaySplit());
- this.el.btnAddActivity.addEventListener('click', () => this.addActivity());
+ this.el.btnSaveMain.addEventListener('click', () => this.saveCurrentTimer());
+ this.el.btnSaveBuilder.addEventListener('click', () => this.saveCurrentTimer());
this.el.brandLink.addEventListener('click', (e) => {
e.preventDefault();
@@ -147,6 +164,28 @@ const app = {
this.el.darkModeToggle.addEventListener('click', () => this.toggleDarkMode());
this.el.formatSelect.addEventListener('change', () => this.updateDisplay());
this.el.settingIsCountdown.addEventListener('change', () => this.resetTimer());
+ this.el.lapSort.addEventListener('change', () => this.renderHistory());
+
+ this.el.savedTimersDropdown.addEventListener('change', (e) => {
+ if (e.target.value) {
+ this.loadTimer(e.target.value);
+ }
+ });
+
+ this.el.btnDeleteSaved.addEventListener('click', () => {
+ const id = this.el.savedTimersDropdown.value;
+ if (id) {
+ this.deleteTimer(id);
+ } else {
+ alert('Please select a session to delete.');
+ }
+ });
+
+ this.el.sessionTitle.addEventListener('input', (e) => {
+ if (this.currentMode) {
+ this.sessionTitles[this.currentMode] = e.target.value;
+ }
+ });
},
updateLandingClock() {
@@ -181,19 +220,25 @@ const app = {
this.switchView('timer');
this.resetTimer();
+ // Update session title for the specific mode
+ this.el.sessionTitle.value = this.sessionTitles[modeKey] || '';
+
// UI Adjustments
this.el.btnPause.classList.toggle('d-none', !mode.allowPause);
this.el.btnLap.classList.toggle('d-none', !mode.hasLaps);
this.el.btnNext.classList.toggle('d-none', !mode.hasRelay);
+ this.el.btnSaveMain.classList.toggle('d-none', !mode.canSave || mode.isCustom);
this.el.timerSideColumn.classList.toggle('d-none', !mode.hasLaps && !mode.hasRelay && !mode.isCustom);
if (mode.isCustom) {
this.el.customBuilder.classList.remove('d-none');
this.el.historySection.classList.add('d-none');
+ this.el.savedTimersContainer.classList.remove('d-none');
} else {
this.el.customBuilder.classList.add('d-none');
this.el.historySection.classList.remove('d-none');
+ this.el.savedTimersContainer.classList.toggle('d-none', !mode.canSave);
}
this.el.countdownInputs.classList.toggle('d-none', modeKey !== 'countdown');
@@ -201,6 +246,7 @@ const app = {
this.el.customOptions.classList.toggle('d-none', !mode.isCustom);
this.el.restAlertContainer.classList.toggle('d-none', !mode.isCustom);
this.el.activeActivityName.classList.add('d-none');
+ this.el.lapSort.classList.add('d-none');
// Options Box adjustments
this.el.optCountdownContainer.classList.toggle('d-none', !mode.canToggleCountdown && !mode.forceCountdown);
@@ -223,18 +269,28 @@ const app = {
this.el.listTitle.textContent = 'Relay Splits';
this.el.listHead.innerHTML = '
| Participant | Total Time | Split | Status |
';
} else if (mode.isCustom) {
- this.el.listTitle.textContent = 'Activities Completed';
+ this.el.listTitle.textContent = 'Activities';
this.el.listHead.innerHTML = '| Activity | Duration | Status |
';
}
+ this.loadSavedTimers();
this.updateDisplay();
},
handleStartClick() {
if (!this.el.sessionTitle.value.trim()) {
- alert('Please enter a Session Name or Title before starting.');
- this.el.sessionTitle.focus();
- return;
+ if (this.currentMode === 'custom') {
+ alert('Please enter a Session Name or Title before starting.');
+ this.el.sessionTitle.focus();
+ return;
+ } else {
+ // Default to current day and time
+ const now = new Date();
+ this.el.sessionTitle.value = now.toLocaleString();
+ if (this.currentMode) {
+ this.sessionTitles[this.currentMode] = this.el.sessionTitle.value;
+ }
+ }
}
if (this.isRunning && !this.isPaused) return;
@@ -297,7 +353,7 @@ const app = {
if (mode.isCustom && !this.isPaused) {
this.currentActivityIndex = 0;
this.history = [];
- this.renderHistory();
+
this.el.activeActivityName.classList.remove('d-none');
this.el.activeActivityName.textContent = this.customActivities[0].name;
@@ -307,6 +363,12 @@ const app = {
// Transition: Builder -> History
this.el.customBuilder.classList.add('d-none');
this.el.historySection.classList.remove('d-none');
+ this.el.savedTimersContainer.classList.add('d-none');
+
+ // Add first activity as "in-progress"
+ this.updateCurrentActivityStatus('in-progress');
+ } else if (mode.isCustom && this.isPaused) {
+ this.updateCurrentActivityStatus('in-progress');
}
if (this.isPaused) {
@@ -326,6 +388,7 @@ const app = {
this.el.btnReset.disabled = true;
this.el.sessionTitle.disabled = true;
this.el.mainTimer.classList.remove('timer-finish');
+ this.el.mainTimer.classList.remove('timer-alert');
// Lock inputs
if (this.el.inputH) this.el.inputH.disabled = true;
@@ -346,11 +409,16 @@ const app = {
this.el.btnStart.disabled = false;
this.el.btnPause.disabled = true;
this.el.btnReset.disabled = false;
+
+ if (this.currentMode === 'custom') {
+ this.updateCurrentActivityStatus('paused');
+ }
},
stopTimer() {
if (!this.isRunning && !this.isPaused) return;
+ const wasRunning = this.isRunning;
this.isRunning = false;
this.isPaused = false;
cancelAnimationFrame(this.animationId);
@@ -359,11 +427,18 @@ const app = {
this.el.btnPause.disabled = true;
this.el.btnStop.disabled = true;
this.el.btnReset.disabled = false;
+ this.el.mainTimer.classList.remove('timer-alert');
if (this.currentMode === 'lap') {
this.recordLap();
+ // Place list under timer
+ this.el.lapResultsContainer.classList.remove('d-none');
+ this.el.lapResultsContainer.appendChild(this.el.historySection);
+ this.el.lapSort.classList.remove('d-none');
} else if (this.currentMode === 'relay') {
this.recordRelaySplit(true);
+ } else if (this.currentMode === 'custom' && wasRunning) {
+ this.updateCurrentActivityStatus('Canceled');
}
},
@@ -379,6 +454,7 @@ const app = {
this.el.listBody.innerHTML = '';
this.el.mainTimer.classList.remove('timer-finish');
+ this.el.mainTimer.classList.remove('timer-alert');
this.el.btnReset.disabled = true;
this.el.btnStart.disabled = false;
@@ -396,6 +472,16 @@ const app = {
if (this.currentMode === 'custom') {
this.el.customBuilder.classList.remove('d-none');
this.el.historySection.classList.add('d-none');
+ this.el.savedTimersContainer.classList.remove('d-none');
+ // Ensure history section is back in side column if it was moved (though only lap moves it)
+ this.el.timerSideColumn.querySelector('.card-precise').appendChild(this.el.historySection);
+ }
+
+ if (this.currentMode === 'lap') {
+ // Move history back to side column
+ this.el.timerSideColumn.querySelector('.card-precise').appendChild(this.el.historySection);
+ this.el.lapResultsContainer.classList.add('d-none');
+ this.el.lapSort.classList.add('d-none');
}
this.updateDisplay();
@@ -445,7 +531,10 @@ const app = {
nextActivity() {
const isCountdownMode = this.el.settingIsCountdown.checked;
- this.recordCustomActivity();
+
+ // Mark current as OK
+ this.updateCurrentActivityStatus('OK');
+
this.currentActivityIndex++;
if (this.currentActivityIndex < this.customActivities.length) {
@@ -456,6 +545,8 @@ const app = {
this.startTime = performance.now();
this.lastPlayedSecond = -1;
this.beep(660, 0.2);
+
+ this.updateCurrentActivityStatus('in-progress');
} else {
this.updateDisplay();
this.stopTimer();
@@ -463,19 +554,46 @@ const app = {
}
},
+ updateCurrentActivityStatus(status) {
+ const act = this.customActivities[this.currentActivityIndex];
+ if (!act) return;
+
+ const existingIdx = this.history.findIndex(h => h.index === this.currentActivityIndex);
+ if (existingIdx !== -1) {
+ this.history[existingIdx].status = status;
+ } else {
+ this.history.push({
+ index: this.currentActivityIndex,
+ id: act.name,
+ delta: act.duration,
+ isRest: act.isRest,
+ status: status
+ });
+ }
+ this.renderHistory();
+ },
+
checkAlerts(ms) {
const currentAct = this.customActivities[this.currentActivityIndex];
const isRest = currentAct && currentAct.isRest;
const enabled = isRest ? this.el.alertRestPreEnd.checked : this.el.alertPreEnd.checked;
- if (!enabled) return;
+ if (!enabled) {
+ this.el.mainTimer.classList.remove('timer-alert');
+ return;
+ }
const secondsLeft = Math.ceil(ms / 1000);
const warningSec = parseInt(this.el.alertPreEndSec.value) || 0;
- if (secondsLeft <= warningSec && secondsLeft > 0 && secondsLeft !== this.lastPlayedSecond) {
- this.lastPlayedSecond = secondsLeft;
- this.beep(isRest ? 330 : 523, 0.1);
+ if (secondsLeft <= warningSec && secondsLeft > 0) {
+ this.el.mainTimer.classList.add('timer-alert');
+ if (secondsLeft !== this.lastPlayedSecond) {
+ this.lastPlayedSecond = secondsLeft;
+ this.beep(isRest ? 330 : 523, 0.1);
+ }
+ } else {
+ this.el.mainTimer.classList.remove('timer-alert');
}
},
@@ -485,10 +603,11 @@ const app = {
this.el.mainTimer.textContent = this.formatTime(ms, format);
+ // Only show sub-timer if format is not milliseconds as requested
if (format !== 'hh:mm:ss.ms') {
- this.el.subTimer.textContent = this.formatTime(ms, 'hh:mm:ss.ms');
- this.el.subTimer.classList.remove('d-none');
+ this.el.subTimer.classList.add('d-none');
} else {
+ // If main format IS milliseconds, we don't need a sub-timer showing the same thing
this.el.subTimer.classList.add('d-none');
}
},
@@ -562,39 +681,31 @@ const app = {
this.renderHistory();
},
- recordCustomActivity() {
- const act = this.customActivities[this.currentActivityIndex];
-
- // Skip rest activities in completed list as requested
- if (act.isRest) return;
-
- this.history.push({
- id: act.name,
- delta: act.duration,
- status: 'OK'
- });
-
- this.renderHistory();
- },
-
renderHistory() {
if (this.currentMode === 'lap') {
+ let sortedHistory = this.history.slice();
+ const sortVal = this.el.lapSort.value;
+
+ if (sortVal === 'best') {
+ sortedHistory.sort((a, b) => a.delta - b.delta);
+ } else if (sortVal === 'worse') {
+ sortedHistory.sort((a, b) => b.delta - a.delta);
+ } else {
+ sortedHistory.reverse();
+ }
+
let minDelta = Infinity;
- let bestId = -1;
this.history.forEach(h => {
- if (h.delta < minDelta) {
- minDelta = h.delta;
- bestId = h.id;
- }
+ if (h.delta < minDelta) minDelta = h.delta;
});
- this.el.listBody.innerHTML = this.history.slice().reverse().map(h => `
-
+ this.el.listBody.innerHTML = sortedHistory.map(h => `
+
| #${h.id} |
${this.formatTime(h.time, 'hh:mm:ss.ms')} |
+${this.formatTime(h.delta, 'seconds.ms')} |
- ${h.id === bestId ? 'BEST' : ''}
+ ${h.delta === minDelta ? 'BEST' : ''}
|
`).join('');
@@ -610,97 +721,82 @@ const app = {
`).join('');
} else if (this.currentMode === 'custom') {
- this.el.listBody.innerHTML = this.history.slice().reverse().map(h => `
-
- | ${h.id} |
- ${this.formatTime(h.delta, 'hh:mm:ss')} |
-
- ${h.status}
- |
-
- `).join('');
+ this.el.listBody.innerHTML = this.history.slice().reverse().map(h => {
+ let badgeClass = 'bg-success';
+ if (h.status === 'in-progress') badgeClass = 'bg-primary';
+ if (h.status === 'paused') badgeClass = 'bg-warning text-dark';
+ if (h.status === 'Canceled') badgeClass = 'bg-danger';
+
+ return `
+
+ | ${h.id} |
+ ${this.formatTime(h.delta, 'hh:mm:ss')} |
+
+ ${h.status}
+ |
+
+ `;
+ }).join('');
}
},
// Custom Activity Builder
renderCustomBuilder() {
- this.el.activityList.innerHTML = this.customActivities.map((act, i) => `
-
+ let totalMs = 0;
+ this.el.activityList.innerHTML = this.customActivities.map((act, i) => {
+ totalMs += act.duration;
+ const isFirst = i === 0;
+ return `
+
${act.isRest ? `
-
-
Rest
-
-
-
-
-
-
-
-
-
-
+
+
+
+
+
+
` : `
-
-
-
+
+
-
-
-
-
-
-
+
+
-
-
-
-
-
-
-
-
+
+
+
+
+
+
`}
- `).join('');
- },
-
- onDragStart(index, event) {
- this.draggedItemIndex = index;
- event.dataTransfer.effectAllowed = 'move';
- // Add a small delay to make the actual element transparent while dragging
- setTimeout(() => {
- event.target.classList.add('dragging');
- }, 0);
- },
-
- onDragOver(index, event) {
- event.preventDefault();
- event.dataTransfer.dropEffect = 'move';
- },
-
- onDrop(index, event) {
- event.preventDefault();
- if (this.draggedItemIndex === null || this.draggedItemIndex === index) return;
+ `}).join('');
- const item = this.customActivities.splice(this.draggedItemIndex, 1)[0];
- this.customActivities.splice(index, 0, item);
-
- this.draggedItemIndex = null;
- this.renderCustomBuilder();
+ if (this.el.customTotalDuration) {
+ this.el.customTotalDuration.textContent = `Total: ${this.formatTime(totalMs, 'hh:mm:ss')}`;
+ }
},
addActivity() {
@@ -708,8 +804,13 @@ const app = {
this.renderCustomBuilder();
},
+ addActivityAfter(index) {
+ this.customActivities.splice(index + 1, 0, { name: `Activity ${this.customActivities.length + 1}`, duration: 60000, isRest: false });
+ this.renderCustomBuilder();
+ },
+
addRestAfter(index) {
- const restDuration = (parseInt(this.el.settingRestDuration.value) || 30) * 1000;
+ const restDuration = (parseInt(this.el.settingRestDuration.value) || 5) * 1000;
this.customActivities.splice(index + 1, 0, { name: 'Rest', duration: restDuration, isRest: true });
this.renderCustomBuilder();
},
@@ -746,6 +847,155 @@ const app = {
this.renderCustomBuilder();
},
+ // Saved Timers Logic
+ async saveCurrentTimer() {
+ const name = this.el.sessionTitle.value.trim();
+ if (!name) {
+ alert('Please enter a session name to save.');
+ this.el.sessionTitle.focus();
+ return;
+ }
+
+ const config = {
+ mode: this.currentMode,
+ isCountdown: this.el.settingIsCountdown.checked,
+ format: this.el.formatSelect.value
+ };
+
+ if (this.currentMode === 'custom') {
+ config.activities = this.customActivities;
+ } else if (this.currentMode === 'countdown') {
+ config.duration = {
+ h: this.el.inputH.value,
+ m: this.el.inputM.value,
+ s: this.el.inputS.value
+ };
+ }
+
+ try {
+ const resp = await fetch('api/timers.php', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ name, config })
+ });
+ const result = await resp.json();
+ if (result.success) {
+ this.loadSavedTimers();
+ alert('Session saved successfully!');
+
+ if (this.currentMode === 'custom') {
+ this.customActivities = [{ name: 'Activity 1', duration: 60000, isRest: false }];
+ this.el.sessionTitle.value = '';
+ if (this.currentMode) this.sessionTitles[this.currentMode] = '';
+ this.renderCustomBuilder();
+ this.resetTimer();
+ }
+ } else {
+ alert('Error saving session: ' + result.error);
+ }
+ } catch (e) {
+ console.error(e);
+ alert('Failed to connect to server.');
+ }
+ },
+
+ async loadSavedTimers() {
+ try {
+ const resp = await fetch('api/timers.php');
+ const result = await resp.json();
+ if (result.success) {
+ this.renderSavedTimers(result.data);
+ }
+ } catch (e) {
+ console.error('Failed to load saved timers:', e);
+ }
+ },
+
+ renderSavedTimers(timers) {
+ if (!timers || timers.length === 0) {
+ this.el.savedTimersDropdown.innerHTML = '
';
+ return;
+ }
+
+ let html = '
';
+ timers.forEach(t => {
+ const config = JSON.parse(t.config);
+ // Show custom timers even if they don't have a mode yet (backwards compat)
+ if (config.mode === this.currentMode || (this.currentMode === 'custom' && !config.mode)) {
+ const durationStr = this.getDurationStringFromConfig(config);
+ const displayLabel = durationStr ? `${t.name} (${durationStr})` : t.name;
+ html += `
`;
+ }
+ });
+ this.el.savedTimersDropdown.innerHTML = html;
+ },
+
+ getDurationStringFromConfig(config) {
+ let totalMs = 0;
+ if (config.mode === 'custom' || (!config.mode && this.currentMode === 'custom')) {
+ if (config.activities) {
+ config.activities.forEach(a => totalMs += (a.duration || 0));
+ }
+ } else if (config.mode === 'countdown' && config.duration) {
+ const h = parseInt(config.duration.h) || 0;
+ const m = parseInt(config.duration.m) || 0;
+ const s = parseInt(config.duration.s) || 0;
+ totalMs = ((h * 3600) + (m * 60) + s) * 1000;
+ } else {
+ return null;
+ }
+
+ if (totalMs <= 0) return null;
+ return this.formatTime(totalMs, 'hh:mm:ss');
+ },
+
+ async loadTimer(id) {
+ try {
+ const resp = await fetch('api/timers.php');
+ const result = await resp.json();
+ if (result.success) {
+ const timer = result.data.find(t => t.id == id);
+ if (timer) {
+ const config = JSON.parse(timer.config);
+ this.el.sessionTitle.value = timer.name;
+ if (this.currentMode) this.sessionTitles[this.currentMode] = timer.name;
+ this.el.settingIsCountdown.checked = !!config.isCountdown;
+ this.el.formatSelect.value = config.format || 'hh:mm:ss.ms';
+
+ if (config.mode === 'custom' || (!config.mode && this.currentMode === 'custom')) {
+ this.customActivities = config.activities || [];
+ this.renderCustomBuilder();
+ } else if (config.mode === 'countdown' && config.duration) {
+ this.el.inputH.value = config.duration.h || '';
+ this.el.inputM.value = config.duration.m || '';
+ this.el.inputS.value = config.duration.s || '';
+ }
+
+ this.resetTimer();
+ }
+ }
+ } catch (e) {
+ console.error(e);
+ }
+ },
+
+ async deleteTimer(id) {
+ if (!confirm('Are you sure you want to delete this saved session?')) return;
+
+ try {
+ const resp = await fetch(`api/timers.php?id=${id}`, { method: 'DELETE' });
+ const result = await resp.json();
+ if (result.success) {
+ this.loadSavedTimers();
+ alert('Session deleted.');
+ } else {
+ alert('Error deleting: ' + result.error);
+ }
+ } catch (e) {
+ console.error(e);
+ }
+ },
+
playCompletionAlert() {
this.el.mainTimer.classList.add('timer-finish');
if (this.el.alertCompletion.checked) {
diff --git a/db/migrations/20260303_create_saved_timers.sql b/db/migrations/20260303_create_saved_timers.sql
new file mode 100644
index 0000000..bb8647f
--- /dev/null
+++ b/db/migrations/20260303_create_saved_timers.sql
@@ -0,0 +1,6 @@
+CREATE TABLE IF NOT EXISTS saved_timers (
+ id INT AUTO_INCREMENT PRIMARY KEY,
+ name VARCHAR(255) NOT NULL,
+ config TEXT NOT NULL,
+ created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
+);
\ No newline at end of file
diff --git a/index.php b/index.php
index d8a2b90..20e2b15 100644
--- a/index.php
+++ b/index.php
@@ -111,6 +111,91 @@ $projectImage = $_SERVER['PROJECT_IMAGE_URL'] ?? '';
+
+
+
@@ -120,8 +205,15 @@ $projectImage = $_SERVER['PROJECT_IMAGE_URL'] ?? '';
-
Activities Completed
-
+
+
Activities Completed
+
+
+
@@ -140,10 +232,16 @@ $projectImage = $_SERVER['PROJECT_IMAGE_URL'] ?? '';
-
Activities Chain
-
+
+
Activities
+
+
+
+
+
-
@@ -154,116 +252,14 @@ $projectImage = $_SERVER['PROJECT_IMAGE_URL'] ?? '';
-
-
-
-
Activity Name
-
-
-
00:00:00.000
-
00:00:00
-
-
-
-
-
-
-
-
-
-
-
-
-
-
+
+
-
+
Activity Name
+
+
+
00:00:00.000
+
00:00:00
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Saved Sessions
+
+
+
+
+
+
+
+