> For the complete documentation index, see [llms.txt](https://tyrix.gitbook.io/docs/llms.txt). Markdown versions of documentation pages are available by appending `.md` to page URLs; this page is available as [Markdown](https://tyrix.gitbook.io/docs/script-resources/tyrix-multi-job/hours-tracking.md).

# Hours Tracking

**Hours tracking** records how long each player spends working each of their jobs. Two numbers are kept per player per job: an all-time total that never resets, and a weekly total that zeroes on a reset day you choose. On top of those, bosses can set a **weekly goal** per rank, which turns the raw numbers into progress bars and roster stats that answer "who is actually showing up".

The whole subsystem is behind one switch:

```lua
Config.hoursTracking = {
    enabled = true,
    weeklyResetDay = 1,         -- Day to reset weekly hours (1=Monday, 7=Sunday)
    defaultWeeklyGoal = 5.00,   -- Default weekly goal in hours
    saveInterval = 5,           -- Auto-save interval in minutes
}
```

| Setting             | Default | Effect                                                                         |
| ------------------- | ------- | ------------------------------------------------------------------------------ |
| `enabled`           | `true`  | Master switch for tracking, the reset sweeps and every hours element in the UI |
| `weeklyResetDay`    | `1`     | Day the week rolls over — `1` is Monday through `7` is Sunday                  |
| `defaultWeeklyGoal` | `5.00`  | Goal in hours used for any rank with no goal saved for it                      |
| `saveInterval`      | `5`     | Minutes between background flushes of open sessions to the database            |

Set `enabled = false` and the hours UI disappears everywhere rather than showing zeroes: the per-job panel in the jobs menu drops its hours block, the boss Employees tab renders without progress bars or the On Track tile, the Overview's Goal Progress tile reads `—` / "Hours disabled" and stops being clickable, and the Weekly Goals quick action is hidden.

Server-side, every write path and both reset sweeps check the same flag, so nothing accumulates while it is off. Time that players banked before you disabled it stays in the database untouched and reappears if you switch tracking back on.

Jobs and ranks themselves come from your framework's `jobs` and `job_grades` tables — this resource only tracks time against the jobs a player holds. See [Configuration](/docs/script-resources/tyrix-multi-job/configuration.md) for the rest of the settings.

## How time is counted

Hours accrue only while a player is **clocked into** that specific job. Being on a job's roster does nothing on its own, and time spent on the off-duty job (`Config.offDutyJob`) is never credited anywhere.

The model is a single timestamp, not a ticking counter. Going on duty writes the current server time into that player's row for that job — the session anchor. A session starts whenever a player lands on a job: clocking in from the menu, being hired or assigned the job by any script, or reconnecting while still employed. Nothing is added to their totals at that moment.

A session ends when the server measures the gap between the anchor and now, adds it to both the all-time total and the weekly total, and clears the anchor.

A session ends when the player:

* Clocks out from the jobs menu, which also returns them to the off-duty job.
* Lands on a **different** job by any route, which settles the old session before anchoring the new one — a player only ever has one running session.
* Quits the job or is fired.
* Disconnects.
* Gets picked up by the background flush described in the next section.

Time is credited in decimal hours, stored to two decimal places — `1.50` is one hour thirty minutes, `0.25` is fifteen minutes. The UI rounds to one decimal for display, so a player who worked eight minutes sees `0.1h` and may report that "nothing was counted". The database has the real value.

Because a job can only ever hold one anchor, clocking into a job you are already clocked into is deliberately a no-op — the existing anchor is left alone rather than overwritten. Without that guard, a player spamming clock-in would reset their own start time and silently throw away the session so far.

{% hint style="info" %}
Reconnecting resumes tracking automatically. The disconnect settles the open session and commits its hours; when the player comes back still wearing the job, a fresh session is anchored the moment their data loads — no trip to the jobs menu required. Time spent offline is never credited, because the old session was settled at (or shortly after) the disconnect.
{% endhint %}

## Crash and disconnect safety

Long sessions are not left to a single write at the end. A background thread wakes every `saveInterval` minutes, finds every row with an open anchor, and settles it:

| Situation                  | What the flush does                                                                          |
| -------------------------- | -------------------------------------------------------------------------------------------- |
| Player is still online     | Credits the elapsed time, then re-anchors to now so the same minutes are never counted twice |
| Player is no longer online | Credits the elapsed time and clears the anchor entirely                                      |

That second row is the safety net for sessions that were never closed properly — a player whose connection died in a way that skipped the disconnect handler, or one who was online when the server process died. It also means a clean shutdown is fully covered: players are dropped, the disconnect handler settles each open session and logs a line like `saved 2.35 hours for license:… on disconnect` (visible with `Config.Debug`), and nothing is left dangling.

A hard crash is where the detail matters, so be precise about what is guaranteed. Everything already flushed is committed to the database and cannot be lost — worst case is the minutes since the last flush, bounded by `saveInterval`. Those minutes are not thrown away either; they are settled the next time the flush thread sees the row after the server is back.

But the anchor is a wall-clock timestamp, not a measure of server uptime, so that settlement counts real elapsed time including the outage. If your server was down for six hours with an open anchor, the flush that finally closes it credits roughly six hours to that player, up to the hard cap covered in [Safety limits](#safety-limits).

That is the usual explanation behind "why does this player have hours they did not work". Lowering `saveInterval` shrinks both the loss window and the size of any post-outage over-credit. The cost is modest either way — each pass is a fixed handful of set-based statements regardless of player count, so even large servers can run a shorter interval if they want tighter bounds.

## The weekly reset

`weeklyResetDay` picks the day the week rolls over, using `1` for Monday through `7` for Sunday. The boundary is midnight in the **server's** local timezone, and the resulting date is stamped on each hours row so the server can tell which week a row's numbers belong to.

At the boundary, weekly hours go back to zero. All-time totals are never touched by a reset — they only ever go up.

Rather than relying on a single scheduled tick that a restart could miss, the resource re-derives the current week boundary and compares it against each row from four places:

* A sweep on resource start, once the schema is ready.
* An hourly sweep for as long as the server stays up.
* On demand when a player clocks into a job.
* On demand when a player opens the jobs menu and their hours are fetched.

The start-up sweep is what makes an offline boundary safe. A server that was down across the reset day still resets correctly on its next boot, and so does a row belonging to a player who has not logged in for a month — nobody has to be online for their week to roll over.

Rows with an open anchor are handled separately during a sweep, because they straddle the boundary. Their in-flight session is settled into the all-time total first, weekly hours are zeroed, and the anchor is moved to now. Pre-boundary time is therefore preserved in the all-time figure and does not get credited to the new week.

When a sweep changes anything it prints a summary:

```
[tyrix_multijob] weekly reset | 42 rows rolled into week 2026-07-27
```

{% hint style="warning" %}
Because the sweep runs hourly and the computed week boundary is cached for an hour, weekly figures can lag the boundary by an hour or two for a player who does not open their menu — opening the jobs menu is the fast path that rolls their own rows over, once that cached boundary has refreshed.

Changing `weeklyResetDay` on a live server moves the boundary date, so weekly hours may reset one extra time the next time a sweep runs. Pick your reset day before launch if you can, and change it right after a reset rather than mid-week.
{% endhint %}

## Weekly goals

A goal is a target number of hours per week, stored **per job and per rank** — not per player. Bosses set them in the Weekly Goals tab of the boss menu, choosing a preset or entering a custom value; see [Boss menu → Weekly goals](/docs/script-resources/tyrix-multi-job/boss-menu.md).

Any rank with no goal saved for it falls back to `Config.hoursTracking.defaultWeeklyGoal`, which is why goals work sensibly on a fresh install with an empty goals table.

Goals are validated server-side: the value must be between 0 and 168 and is floored to whole hours, since every place that renders a goal compares it as an integer. Only a boss-grade employee of that job can change one, and each change is written to Discord if logging is on — see [Discord logging](/docs/script-resources/tyrix-multi-job/discord-logging.md).

Because the goal is attached to the rank, promoting or demoting someone changes their target immediately. An employee who was comfortably over a junior rank's goal can appear behind on the day they are promoted; that is expected, not a bug.

Goals surface in four places:

| Where                    | What it shows                                                                                                                                                                    |
| ------------------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Jobs menu, per-job panel | The player's all-time hours plus a `weekly / goal` progress bar for that job — see [Jobs menu](/docs/script-resources/tyrix-multi-job/jobs-menu.md)                              |
| Boss menu, Employees tab | A progress bar per employee, a percent-of-goal figure on the expanded row, and an On Track tile counting how many of the currently filtered employees have met their rank's goal |
| Boss menu, Overview      | The Goal Progress tile: the percentage of the roster meeting their rank's goal, with the met/total count underneath                                                              |
| Boss menu, Overview      | Top Employees - This Week, the three employees with the highest weekly hours                                                                                                     |

The Overview figures are served from a short-lived cache and refreshed when something relevant changes, so a boss editing a goal sees the tile move without reopening the menu.

## Safety limits

Every settlement passes through a sanitiser before it touches the database. A negative result becomes zero, and anything above **168 hours** — one full week — is clamped to 168.

This exists so that a stuck anchor cannot award an absurd total. A row whose anchor survived a multi-day outage, or a clock skew that moves the server clock backwards, gets a bounded credit instead of thousands of hours.

Note that 168 is a cap per settlement, not a weekly cap: a player who genuinely clocks in and out repeatedly across a week accumulates normally, and each individual settlement is checked on its own. Goal values are bounded by the same 168-hour ceiling.

If you do find an inflated figure, correcting it is a straightforward `UPDATE` on the row — the resource only ever adds to the totals it finds, so a corrected value stays corrected.

## Where the data lives

Hours live in `tyrix_multijob_hours`, one row per player per job. It is created automatically on resource start alongside the other tables, so there is nothing to import.

| Column          | Meaning                                                               |
| --------------- | --------------------------------------------------------------------- |
| `identifier`    | The player's framework identifier — ESX license or QB citizenid       |
| `job`           | Job name, matching your framework's `jobs` table                      |
| `clock_in_time` | The session anchor as a Unix timestamp, or `NULL` when not clocked in |
| `total_hours`   | All-time hours on this job, in decimal hours                          |
| `weekly_hours`  | Hours since the current week boundary                                 |
| `week_start`    | The week boundary date these weekly hours belong to                   |

Goals live separately in `tyrix_multijob_goals`, keyed on job plus grade, holding one `weekly_goal` value each. Full schema notes are on the [Database](/docs/script-resources/tyrix-multi-job/database.md) page.

Two things to know if you ever edit these rows by hand:

* Clearing `clock_in_time` to `NULL` does not settle the session, it discards it — the elapsed time is never credited.
* Quitting or being fired removes the player's roster row only; their hours row is left in place. That is intentional, so a rehired employee keeps their history instead of starting from zero. It also means wiping someone's record is a deliberate delete on `tyrix_multijob_hours`, not a side effect of firing them.

If a figure still looks wrong after all of this, [Troubleshooting](/docs/script-resources/tyrix-multi-job/troubleshooting.md) covers the console output to check first.
