diff --git a/worktimer@imanilchaudhari/README.md b/worktimer@imanilchaudhari/README.md new file mode 100644 index 000000000..5caf83ea5 --- /dev/null +++ b/worktimer@imanilchaudhari/README.md @@ -0,0 +1,36 @@ +# Work Timer + +A Cinnamon desklet for tracking your work session with automatic reminders as time runs out. + +## Features + +- Countdown clock showing remaining work time (HH:MM or HH:MM:SS) +- Progress bar that shrinks and changes colour as time runs out — green → amber → orange → red +- Desktop notifications at fixed milestones: 1 hour, 30 min, 15 min, 5 min, 1 min remaining +- Optional periodic reminders on a configurable interval +- Optional sound alert on each reminder using built-in system sounds +- Start, Pause, and Reset controls + +## Settings + +| Setting | Description | +|---|---| +| Working hours | Length of your work session (1–16 hours) | +| Reminder interval | How often to receive periodic reminders (5, 15, 30, or 60 min) | +| Show seconds | Toggle seconds display on the clock | +| Play sound on reminders | Enable or disable audio alerts | +| Reminder sound | Choose from a set of built-in system sounds | + +## Installation + +Install via the Cinnamon Desklets manager (right-click desktop → Add Desklets), or manually: + +```bash +cp -r files/worktimer@imanilchaudhari ~/.local/share/cinnamon/desklets/ +``` + +Then reload Cinnamon (Alt+F2 → `r` → Enter) and add the desklet from the desklets manager. + +## Author + +[imanilchaudhari](https://github.com/imanilchaudhari) diff --git a/worktimer@imanilchaudhari/files/worktimer@imanilchaudhari/desklet.js b/worktimer@imanilchaudhari/files/worktimer@imanilchaudhari/desklet.js new file mode 100644 index 000000000..0eef116b3 --- /dev/null +++ b/worktimer@imanilchaudhari/files/worktimer@imanilchaudhari/desklet.js @@ -0,0 +1,229 @@ +const Desklet = imports.ui.desklet; +const St = imports.gi.St; +const GLib = imports.gi.GLib; +const Gio = imports.gi.Gio; +const Settings = imports.ui.settings; +const Main = imports.ui.main; +const Util = imports.misc.util; + +const UUID = "worktimer@imanilchaudhari"; + +// thresholds where we send a one-time reminder +const REMINDER_MILESTONES = [ + { seconds: 3600, label: "1 hour" }, + { seconds: 1800, label: "30 minutes" }, + { seconds: 900, label: "15 minutes" }, + { seconds: 300, label: "5 minutes" }, + { seconds: 60, label: "1 minute" } +]; + +function pad(n) { + return n < 10 ? '0' + n : String(n); +} + +function friendlyTime(seconds) { + let h = Math.floor(seconds / 3600); + let m = Math.floor((seconds % 3600) / 60); + if (h > 0 && m > 0) return `${h}h ${m}min`; + if (h > 0) return `${h} hour${h > 1 ? 's' : ''}`; + return `${m} minute${m !== 1 ? 's' : ''}`; +} + +class WorkTimerDesklet extends Desklet.Desklet { + + constructor(metadata, desklet_id) { + super(metadata, desklet_id); + + this.settings = new Settings.DeskletSettings(this, UUID, desklet_id); + this.settings.bindProperty(Settings.BindingDirection.IN, "working-hours", "workingHours", this._onHoursChanged.bind(this), null); + this.settings.bindProperty(Settings.BindingDirection.IN, "reminder-interval", "reminderInterval", null, null); + this.settings.bindProperty(Settings.BindingDirection.IN, "show-seconds", "showSeconds", this._updateDisplay.bind(this), null); + this.settings.bindProperty(Settings.BindingDirection.IN, "sound-enabled", "soundEnabled", null, null); + this.settings.bindProperty(Settings.BindingDirection.IN, "reminder-sound", "reminderSound", null, null); + + this._timerId = null; + this._running = false; + this._seenMilestones = {}; + this._lastPeriodicAt = 0; + + this._initTimer(); + this._buildUI(); + this._updateDisplay(); + } + + _initTimer() { + this._totalSecs = (this.workingHours || 8) * 3600; + this._remaining = this._totalSecs; + this._seenMilestones = {}; + this._lastPeriodicAt = this._totalSecs; + } + + _buildUI() { + this._root = new St.BoxLayout({ vertical: true, style_class: 'wt-root' }); + + this._titleLabel = new St.Label({ text: 'WORK TIMER', style_class: 'wt-title' }); + this._clockLabel = new St.Label({ text: '08:00', style_class: 'wt-clock' }); + this._subLabel = new St.Label({ text: '8 hours remaining', style_class: 'wt-sublabel' }); + + let barBg = new St.Bin({ style_class: 'wt-bar-bg' }); + this._barFill = new St.Bin({ style_class: 'wt-bar-fill' }); + barBg.set_child(this._barFill); + + this._statusLabel = new St.Label({ text: 'Ready — press Start', style_class: 'wt-status' }); + + this._startBtn = new St.Button({ label: '▶ Start', style_class: 'wt-btn wt-btn-primary' }); + this._startBtn.connect('clicked', this._toggleTimer.bind(this)); + + this._resetBtn = new St.Button({ label: '↺ Reset', style_class: 'wt-btn wt-btn-secondary' }); + this._resetBtn.connect('clicked', this._reset.bind(this)); + + let btnRow = new St.BoxLayout({ style_class: 'wt-btnrow' }); + btnRow.add_actor(this._startBtn); + btnRow.add_actor(this._resetBtn); + + this._root.add_actor(this._titleLabel); + this._root.add_actor(this._clockLabel); + this._root.add_actor(this._subLabel); + this._root.add_actor(barBg); + this._root.add_actor(this._statusLabel); + this._root.add_actor(btnRow); + + this.setContent(this._root); + } + + _toggleTimer() { + this._running ? this._pause() : this._start(); + } + + _start() { + if (this._remaining <= 0) return; + this._running = true; + this._startBtn.set_label('⏸ Pause'); + this._statusLabel.set_text('Running…'); + this._timerId = GLib.timeout_add(GLib.PRIORITY_DEFAULT, 1000, this._tick.bind(this)); + } + + _pause() { + this._running = false; + this._startBtn.set_label('▶ Resume'); + this._statusLabel.set_text('Paused'); + this._stopTimer(); + } + + _reset() { + this._stopTimer(); + this._running = false; + this._initTimer(); + this._startBtn.set_label('▶ Start'); + this._statusLabel.set_text('Ready — press Start'); + this._updateDisplay(); + } + + _stopTimer() { + if (this._timerId !== null) { + GLib.source_remove(this._timerId); + this._timerId = null; + } + } + + _tick() { + if (!this._running) return GLib.SOURCE_REMOVE; + + this._remaining = Math.max(0, this._remaining - 1); + this._updateDisplay(); + this._checkReminders(); + + if (this._remaining <= 0) { + this._onFinish(); + return GLib.SOURCE_REMOVE; + } + + return GLib.SOURCE_CONTINUE; + } + + _checkReminders() { + let secs = this._remaining; + + for (let milestone of REMINDER_MILESTONES) { + if (secs === milestone.seconds && !this._seenMilestones[secs]) { + this._seenMilestones[secs] = true; + this._sendReminder( + `Work Timer — ${milestone.label} left`, + `You have ${milestone.label} remaining in your work session.` + ); + return; + } + } + + let interval = (this.reminderInterval || 60) * 60; + if (secs > 0 && secs <= this._lastPeriodicAt - interval) { + this._lastPeriodicAt = secs; + this._sendReminder('Work Timer', `${friendlyTime(secs)} remaining in your work session.`); + } + } + + _sendReminder(title, body) { + Main.notify(title, body); + this._playSound(); + } + + _playSound() { + if (!this.soundEnabled || !this.reminderSound) return; + let f = Gio.File.new_for_path(this.reminderSound); + f.query_info_async( + Gio.FILE_ATTRIBUTE_STANDARD_TYPE, + Gio.FileQueryInfoFlags.NONE, + GLib.PRIORITY_DEFAULT, + null, + (file, res) => { + try { + file.query_info_finish(res); + Util.trySpawn(['paplay', this.reminderSound]); + } catch (e) { + if (!e.matches(Gio.IOErrorEnum, Gio.IOErrorEnum.NOT_FOUND)) + logError(e, 'Error playing reminder sound'); + } + } + ); + } + + _onFinish() { + this._timerId = null; + this._running = false; + this._startBtn.set_label('▶ Start'); + this._statusLabel.set_text('Session complete!'); + this._sendReminder( + 'Work Session Complete!', + `Your ${this.workingHours || 8}-hour work session has ended. Great job!` + ); + } + + _updateDisplay() { + let secs = this._remaining; + let h = Math.floor(secs / 3600); + let m = Math.floor((secs % 3600) / 60); + let s = secs % 60; + + let time = this.showSeconds ? `${pad(h)}:${pad(m)}:${pad(s)}` : `${pad(h)}:${pad(m)}`; + this._clockLabel.set_text(time); + this._subLabel.set_text(`${friendlyTime(secs)} remaining`); + + let pct = this._totalSecs > 0 ? secs / this._totalSecs : 0; + let color = pct > 0.5 ? '#4ade80' : pct > 0.25 ? '#fbbf24' : pct > 0.1 ? '#fb923c' : '#f87171'; + + this._clockLabel.style = `color: ${color};`; + this._barFill.style = `width: ${Math.round(pct * 100)}%; background-color: ${color};`; + } + + _onHoursChanged() { + if (!this._running) this._reset(); + } + + on_desklet_removed() { + this._stopTimer(); + } +} + +function main(metadata, desklet_id) { + return new WorkTimerDesklet(metadata, desklet_id); +} diff --git a/worktimer@imanilchaudhari/files/worktimer@imanilchaudhari/icon.png b/worktimer@imanilchaudhari/files/worktimer@imanilchaudhari/icon.png new file mode 100644 index 000000000..30e61a9f0 Binary files /dev/null and b/worktimer@imanilchaudhari/files/worktimer@imanilchaudhari/icon.png differ diff --git a/worktimer@imanilchaudhari/files/worktimer@imanilchaudhari/metadata.json b/worktimer@imanilchaudhari/files/worktimer@imanilchaudhari/metadata.json new file mode 100644 index 000000000..89fbd34e5 --- /dev/null +++ b/worktimer@imanilchaudhari/files/worktimer@imanilchaudhari/metadata.json @@ -0,0 +1,8 @@ +{ + "uuid": "worktimer@imanilchaudhari", + "name": "Work Timer", + "description": "Work session timer with hourly reminders showing remaining time", + "version": "1.0.0", + "author": "imanilchaudhari", + "max-instances": 1 +} diff --git a/worktimer@imanilchaudhari/files/worktimer@imanilchaudhari/settings-schema.json b/worktimer@imanilchaudhari/files/worktimer@imanilchaudhari/settings-schema.json new file mode 100644 index 000000000..2660917c9 --- /dev/null +++ b/worktimer@imanilchaudhari/files/worktimer@imanilchaudhari/settings-schema.json @@ -0,0 +1,50 @@ +{ + "working-hours": { + "type": "spinbutton", + "default": 8, + "min": 1, + "max": 16, + "step": 1, + "units": "hours", + "description": "Working hours per session", + "tooltip": "Total hours in your work session" + }, + "reminder-interval": { + "type": "combobox", + "default": 60, + "options": { + "Every 5 minutes": 5, + "Every 15 minutes": 15, + "Every 30 minutes": 30, + "Every hour": 60 + }, + "description": "Periodic reminder interval", + "tooltip": "How often to receive remaining-time reminders" + }, + "show-seconds": { + "type": "checkbox", + "default": true, + "description": "Show seconds in display" + }, + "sound-enabled": { + "type": "checkbox", + "default": true, + "description": "Play sound on reminders" + }, + "reminder-sound": { + "type": "combobox", + "default": "/usr/share/sounds/freedesktop/stereo/alarm-clock-elapsed.oga", + "options": { + "Alarm Clock": "/usr/share/sounds/freedesktop/stereo/alarm-clock-elapsed.oga", + "Bell": "/usr/share/sounds/freedesktop/stereo/bell.oga", + "Complete": "/usr/share/sounds/freedesktop/stereo/complete.oga", + "Message": "/usr/share/sounds/freedesktop/stereo/message-new-instant.oga", + "Attention": "/usr/share/sounds/freedesktop/stereo/window-attention.oga", + "Warning": "/usr/share/sounds/freedesktop/stereo/dialog-warning.oga", + "Mint: Dialog": "/usr/share/sounds/LinuxMint/stereo/dialog-information.ogg", + "Mint: System Ready": "/usr/share/sounds/LinuxMint/stereo/system-ready.ogg" + }, + "description": "Reminder sound", + "tooltip": "Sound to play when a reminder fires" + } +} diff --git a/worktimer@imanilchaudhari/files/worktimer@imanilchaudhari/stylesheet.css b/worktimer@imanilchaudhari/files/worktimer@imanilchaudhari/stylesheet.css new file mode 100644 index 000000000..50adc1af6 --- /dev/null +++ b/worktimer@imanilchaudhari/files/worktimer@imanilchaudhari/stylesheet.css @@ -0,0 +1,85 @@ +.wt-root { + padding: 14px 16px; + spacing: 8px; + background-color: rgba(10, 10, 20, 0.88); + border-radius: 14px; + border: 1px solid rgba(255, 255, 255, 0.08); + min-width: 220px; +} + +.wt-title { + font-size: 11px; + font-weight: bold; + color: rgba(255, 255, 255, 0.45); + letter-spacing: 2px; +} + +.wt-clock { + font-size: 46px; + font-weight: bold; + font-family: monospace; + color: #4ade80; +} + +.wt-sublabel { + font-size: 12px; + color: rgba(255, 255, 255, 0.55); +} + +.wt-bar-bg { + height: 6px; + background-color: rgba(255, 255, 255, 0.1); + border-radius: 3px; + /* clip child to rounded corners */ +} + +.wt-bar-fill { + height: 6px; + border-radius: 3px; + background-color: #4ade80; + /* width is set dynamically in JS */ + transition-duration: 800; +} + +.wt-status { + font-size: 11px; + color: rgba(255, 255, 255, 0.4); +} + +.wt-btnrow { + spacing: 8px; +} + +.wt-btn { + border-radius: 7px; + padding: 5px 14px; + font-size: 12px; + font-weight: bold; + border: none; +} + +.wt-btn-primary { + background-color: #166534; + color: #ffffff; +} + +.wt-btn-primary:hover { + background-color: #15803d; +} + +.wt-btn-primary:active { + background-color: #14532d; +} + +.wt-btn-secondary { + background-color: rgba(255, 255, 255, 0.08); + color: rgba(255, 255, 255, 0.7); +} + +.wt-btn-secondary:hover { + background-color: rgba(255, 255, 255, 0.15); +} + +.wt-btn-secondary:active { + background-color: rgba(255, 255, 255, 0.06); +} diff --git a/worktimer@imanilchaudhari/info.json b/worktimer@imanilchaudhari/info.json new file mode 100644 index 000000000..3d1b3e7aa --- /dev/null +++ b/worktimer@imanilchaudhari/info.json @@ -0,0 +1 @@ +{"author": "imanilchaudhari"} diff --git a/worktimer@imanilchaudhari/screenshot.png b/worktimer@imanilchaudhari/screenshot.png new file mode 100644 index 000000000..12300416b Binary files /dev/null and b/worktimer@imanilchaudhari/screenshot.png differ