{
 "cells": [
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "# Compute durations and ages\n",
    "\n",
    "**The question.** You need to answer \"how long between these two moments?\" or \"how old is somebody born on *this* date?\" — and the obvious approach (subtract and divide by 365) gives a wrong answer.\n",
    "\n",
    "Two separate tools: `timedelta` for fixed durations (seconds, hours, days), and calendar-based arithmetic for things that depend on the calendar (months, years, ages). The canonical age-in-years function below is six lines and covers the whole corner."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "from datetime import date, datetime, timedelta\n",
    "\n",
    "# Fixed-duration arithmetic — subtraction gives a timedelta\n",
    "start = datetime(2026, 4, 21, 9, 0)\n",
    "end   = datetime(2026, 4, 22, 15, 30)\n",
    "gap = end - start\n",
    "print('elapsed:', gap)                                # 1 day, 6:30:00\n",
    "print('total seconds:', gap.total_seconds())\n",
    "print('total hours:  ', gap.total_seconds() / 3600)\n",
    "\n",
    "\n",
    "# Age in whole years — calendar-based, not duration-based\n",
    "def age_in_years(dob: date, as_of: date) -> int:\n",
    "    years = as_of.year - dob.year\n",
    "    # Subtract one if the birthday hasn't happened yet this year\n",
    "    if (as_of.month, as_of.day) < (dob.month, dob.day):\n",
    "        years -= 1\n",
    "    return years\n",
    "\n",
    "\n",
    "print('age check — day before birthday:', age_in_years(date(2000, 6, 15), date(2026, 6, 14)))\n",
    "print('age check — on birthday:        ', age_in_years(date(2000, 6, 15), date(2026, 6, 15)))\n",
    "print('age check — day after:          ', age_in_years(date(2000, 6, 15), date(2026, 6, 16)))\n"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "# Variant: humanise a timedelta for display\n",
    "from datetime import timedelta\n",
    "\n",
    "def humanise(td: timedelta) -> str:\n",
    "    total = int(td.total_seconds())\n",
    "    days, rem = divmod(total, 86400)\n",
    "    hours, rem = divmod(rem, 3600)\n",
    "    minutes, _ = divmod(rem, 60)\n",
    "    parts = []\n",
    "    if days:    parts.append(f'{days}d')\n",
    "    if hours:   parts.append(f'{hours}h')\n",
    "    if minutes: parts.append(f'{minutes}m')\n",
    "    return ' '.join(parts) or '0m'\n",
    "\n",
    "print(humanise(timedelta(days=1, hours=6, minutes=30)))\n",
    "print(humanise(timedelta(minutes=45)))\n",
    "print(humanise(timedelta(0)))\n"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "## Why it works\n",
    "\n",
    "Subtracting two datetimes produces a `timedelta`, which is a **fixed** duration in days, seconds, and microseconds. `gap.days` is the integer day component, not the total elapsed days: `timedelta(hours=23).days` is `0`. That's why `.total_seconds()` is the escape hatch — divide by `86400` for total days as a float, by `3600` for total hours.\n",
    "\n",
    "Age-in-years is **not** a `timedelta` problem. Calendar years aren't a fixed length — leap years, the Gregorian rules, and cultural definitions all vary. `(as_of - dob).days / 365.25` is fine for bucketing cohorts (\"average age ≈ 32\"); it's wrong for the integer number of birthdays someone has had. The canonical approach is the one above: subtract the years directly, then adjust by one if the birthday is still to come. Five lines, no approximation."
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "## Trade-offs\n",
    "\n",
    "**Months and years need `relativedelta`.** `timedelta(months=1)` doesn't exist because the language won't guess whether you mean 28, 30, or 31 days. `dateutil.relativedelta` handles calendar shifts correctly, including end-of-month clamping: `date(2026, 1, 31) + relativedelta(months=1)` returns `2026-02-28`, not `2026-03-03`.\n",
    "\n",
    "**Business days need a specialised tool.** If \"days\" means working days (excluding weekends and holidays), neither `timedelta` nor `relativedelta` helps. `numpy.busday_count` or `pandas.tseries.offsets.BusinessDay` with a holiday calendar is the right shape. For country-specific bank holidays, the `holidays` package has a table per country.\n",
    "\n",
    "**Human-readable durations need custom formatting.** `timedelta` prints as `1 day, 6:30:00` — fine for logs, ugly for a UI. The `humanise` helper in the extra cell is enough for most cases; for locale-aware *\"2 hours ago\"* messages, reach for `babel.dates.format_timedelta`."
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "## Related reading\n",
    "\n",
    "- [Avoid common datetime mistakes](https://agilearn.co.uk/guides/dates-and-times/recipes/avoid-common-datetime-mistakes) — including the \"age in days / 365.25\" bug you just saw.\n",
    "- [Convert between time zones](https://agilearn.co.uk/guides/dates-and-times/recipes/convert-between-time-zones) — the time-zone-aware side of datetime work.\n",
    "- [UTC everywhere](https://agilearn.co.uk/guides/dates-and-times/concepts/utc-everywhere) — the design rule that makes the rest easier.\n"
   ]
  }
 ],
 "metadata": {
  "kernelspec": {
   "display_name": "Python 3",
   "language": "python",
   "name": "python3"
  },
  "language_info": {
   "name": "python",
   "version": "3.10"
  }
 },
 "nbformat": 4,
 "nbformat_minor": 5
}