screen-locker/web/src/format.ts
Krzysztof kuhy Rudnicki 7d8daca111 sick days: one shared sickdays pool, 20:00 shutdown floor, one-tap lock button
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
2026-10-16 13:30:39 +02:00

64 lines
2.5 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/** 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
}