mirror of
https://github.com/kuhyx/screen-locker.git
synced 2026-11-14 11:10:58 +01:00
A sick day is now a date in the shared sickdays pool (utils sickdays 0.1.0) and nothing else. Removed: the 7/30/90-day budget, the escalating lockout countdown, the justification form, the commitment prompt and its double debt, the workout-debt counter, the -60 min shutdown cut and its sick_day_state.json, the post-sick-day --verify-workout mode, the sick auto-upgrade, scripts/revoke_sick_day.py and their tests. - Lock screen: "I'm sick" on the retry, no-phone and both Tue-Thu screens; one tap -> sickdays.add_sick_day(today) -> sick floor -> best-effort background sickdays.sync_quietly -> unlock. Refusals (free day, unreadable pool) are shown and the lock stays. - Shutdown: the reset resolves with earned_time.resolve(..., sick=True) (max(20:00, ladder), capped 23:00) and saves the sick lift with the grace lift, so later credits absorb it instead of stacking on 20:00. A day marked after the reset is lifted by the new _sick_floor live pass (every 5-min tick, --sync-only, --apply-bonuses, and the tap itself). - Workout lock and grace floor stand down on a sick day; workouts that day credit normally through the sync paths. - wake-alarm workday_penalty.json is void when its issued_date or penalty_date is a sick day. - Status window, i3 line, CLI --status, MCP flags and web reasons read sickdays. - scripts/migrate_sick_history.py imports sick_history.json's dates via sickdays.import_legacy, verifies and removes the file; idempotent, run by install.sh. - Pins: earned-time 0.11.0, freedays 0.4.0, sickdays 0.1.0 (tags pending); pre-commit 4.7.0, ruff 0.17.1 (freshness gate). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Xfj1sxKH9VBPqpcAni8Jry
64 lines
2.5 KiB
TypeScript
64 lines
2.5 KiB
TypeScript
/** Pure formatting helpers, shared by the three views. */
|
||
|
||
const MINUTE = 60
|
||
const HOUR = 60 * MINUTE
|
||
const DAY = 24 * HOUR
|
||
|
||
/**
|
||
* Render a duration in seconds as a short human age ("3m", "5h", "2d").
|
||
*
|
||
* Returns "unknown" for null so a missing timestamp can never be mistaken for
|
||
* a fresh one — the whole point of the Health view is that silence is visible.
|
||
*/
|
||
export function formatAge(seconds: number | null): string {
|
||
if (seconds === null) return 'unknown'
|
||
if (seconds < 0) return 'in the future'
|
||
if (seconds < MINUTE) return `${String(Math.floor(seconds))}s`
|
||
if (seconds < HOUR) return `${String(Math.floor(seconds / MINUTE))}m`
|
||
if (seconds < DAY) return `${String(Math.floor(seconds / HOUR))}h`
|
||
return `${String(Math.floor(seconds / DAY))}d`
|
||
}
|
||
|
||
/** Render an ISO-8601 timestamp as a local "MMM DD HH:MM" stamp. */
|
||
export function formatStamp(iso: string): string {
|
||
const when = new Date(iso)
|
||
if (Number.isNaN(when.getTime())) return iso
|
||
return when.toLocaleString(undefined, {
|
||
month: 'short',
|
||
day: '2-digit',
|
||
hour: '2-digit',
|
||
minute: '2-digit',
|
||
})
|
||
}
|
||
|
||
/**
|
||
* Turn a decision reason slug into a sentence.
|
||
*
|
||
* Falls back to the slug with underscores stripped, so a reason added to the
|
||
* Python side later still reads sensibly here instead of rendering blank.
|
||
*/
|
||
export function describeReason(reason: string): string {
|
||
const known: Record<string, string> = {
|
||
enforced: 'Locked — no skip condition applied',
|
||
weekly_minimum_met: 'Skipped — weekly minimum already met',
|
||
scheduled_skip_day: 'Skipped — this date was scheduled off',
|
||
relaxed_day: 'Relaxed day (Tue–Thu) — lock optional',
|
||
relaxed_day_already_skipped: 'Skipped — relaxed day already dismissed',
|
||
workout_logged_today: 'Skipped — a workout was logged today',
|
||
early_bird_window_active: 'Skipped — inside the early-bird window',
|
||
early_bird_banked: 'Skipped — early-bird marker banked',
|
||
early_bird_auto_upgraded: 'Skipped — early-bird workout confirmed',
|
||
sick_day: 'Skipped — sick day',
|
||
sick_day_marked: 'Unlocked — marked sick at the lock screen',
|
||
wake_alarm_skip: 'Skipped — earned by the wake alarm',
|
||
heat_skip: 'Skipped — too hot',
|
||
mode_makes_no_decision: 'No decision (status or sync-only run)',
|
||
}
|
||
return known[reason] ?? reason.replace(/_/g, ' ')
|
||
}
|
||
|
||
/** Budget hours the enforcer will apply, given whether today has a workout. */
|
||
export function budgetHours(workoutToday: boolean): number {
|
||
return workoutToday ? 8 : 6
|
||
}
|