From f53c700e02c2670ed0cb8d1b5e95d027317fa6b3 Mon Sep 17 00:00:00 2001 From: Jeff Emmett Date: Mon, 8 Dec 2025 08:15:49 +0100 Subject: [PATCH] Initial commit: International Fixed Calendar converter MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Features: - Web UI with date converter, date range, and full calendar view - Print-friendly calendar output - Python CLI tool for terminal usage - Dockerized with nginx for deployment - Traefik labels for reverse proxy integration Deployed to: https://lunarcal.jeffemmett.com 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- Dockerfile | 28 ++ deploy.sh | 40 +++ docker-compose.yml | 18 + ifc.py | 298 +++++++++++++++ index.html | 876 +++++++++++++++++++++++++++++++++++++++++++++ 5 files changed, 1260 insertions(+) create mode 100644 Dockerfile create mode 100755 deploy.sh create mode 100644 docker-compose.yml create mode 100644 ifc.py create mode 100644 index.html diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..9baffff --- /dev/null +++ b/Dockerfile @@ -0,0 +1,28 @@ +FROM nginx:alpine + +# Copy the static HTML file with proper permissions +COPY index.html /usr/share/nginx/html/index.html +RUN chmod 644 /usr/share/nginx/html/index.html + +# Copy CLI tool for optional use +COPY ifc.py /usr/local/bin/ifc +RUN chmod +x /usr/local/bin/ifc && apk add --no-cache python3 + +# Nginx config for SPA +RUN echo 'server { \ + listen 80; \ + server_name _; \ + root /usr/share/nginx/html; \ + index index.html; \ + location / { \ + try_files $uri $uri/ /index.html; \ + } \ + location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2)$ { \ + expires 1y; \ + add_header Cache-Control "public, immutable"; \ + } \ +}' > /etc/nginx/conf.d/default.conf + +EXPOSE 80 + +CMD ["nginx", "-g", "daemon off;"] diff --git a/deploy.sh b/deploy.sh new file mode 100755 index 0000000..df1a6e9 --- /dev/null +++ b/deploy.sh @@ -0,0 +1,40 @@ +#!/bin/bash +# Deployment script for lunar-calendar to Netcup + +set -e + +REMOTE_HOST="netcup" +REMOTE_DIR="/opt/websites/lunar-calendar" +TUNNEL_ID="a838e9dc-0af5-4212-8af2-6864eb15e1b5" + +echo "=== Deploying lunar-calendar to Netcup ===" + +# 1. Create directory and copy files +echo "[1/4] Copying files to Netcup..." +ssh $REMOTE_HOST "mkdir -p $REMOTE_DIR" +scp -r index.html ifc.py Dockerfile docker-compose.yml $REMOTE_HOST:$REMOTE_DIR/ + +# 2. Build and start container +echo "[2/4] Building and starting Docker container..." +ssh $REMOTE_HOST "cd $REMOTE_DIR && docker compose up -d --build" + +# 3. Add hostname to Cloudflare tunnel config +echo "[3/4] Updating Cloudflare tunnel config..." +ssh $REMOTE_HOST "cat /root/cloudflared/config.yml" | grep -q "lunarcal.jeffemmett.com" || { + ssh $REMOTE_HOST "cat >> /root/cloudflared/config.yml << 'EOF' + - hostname: lunarcal.jeffemmett.com + service: http://localhost:80 +EOF" + ssh $REMOTE_HOST "docker restart cloudflared" +} + +echo "[4/4] Done!" +echo "" +echo "=== Next Steps ===" +echo "Add DNS CNAME in Cloudflare Dashboard:" +echo " Type: CNAME" +echo " Name: lunarcal" +echo " Target: $TUNNEL_ID.cfargotunnel.com" +echo " Proxy: Proxied (orange cloud)" +echo "" +echo "Site will be live at: https://lunarcal.jeffemmett.com" diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..027cf91 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,18 @@ +version: '3.8' + +services: + lunar-calendar: + build: . + container_name: lunar-calendar + restart: unless-stopped + labels: + - "traefik.enable=true" + - "traefik.http.routers.lunarcal.rule=Host(`lunarcal.jeffemmett.com`)" + - "traefik.http.routers.lunarcal.entrypoints=web" + - "traefik.http.services.lunarcal.loadbalancer.server.port=80" + networks: + - traefik-public + +networks: + traefik-public: + external: true diff --git a/ifc.py b/ifc.py new file mode 100644 index 0000000..90a2bcf --- /dev/null +++ b/ifc.py @@ -0,0 +1,298 @@ +#!/usr/bin/env python3 +""" +International Fixed Calendar (IFC) Converter CLI + +Convert between Gregorian calendar and the International Fixed Calendar. + +Usage: + ifc # Show today's date in IFC + ifc 2025-12-25 # Convert Gregorian date to IFC + ifc --to-gregorian 7 15 2025 # Convert IFC (Sol 15, 2025) to Gregorian + ifc --range 2025-01-01 2025-01-31 # Convert date range + ifc --calendar 2025 # Show full year calendar +""" + +import argparse +import sys +from datetime import datetime, timedelta +from typing import Optional, Tuple, NamedTuple + +IFC_MONTHS = [ + 'January', 'February', 'March', 'April', 'May', 'June', + 'Sol', 'July', 'August', 'September', 'October', 'November', 'December' +] + +IFC_WEEKDAYS = ['Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday'] + + +class IFCDate(NamedTuple): + year: int + month: Optional[int] + day: Optional[int] + special: Optional[str] + weekday: Optional[str] + + def __str__(self): + if self.special: + return f"{self.special}, {self.year}" + return f"{IFC_MONTHS[self.month - 1]} {self.day}, {self.year} ({self.weekday})" + + +def is_leap_year(year: int) -> bool: + return (year % 4 == 0 and year % 100 != 0) or (year % 400 == 0) + + +def day_of_year(date: datetime) -> int: + return (date - datetime(date.year, 1, 1)).days + 1 + + +def gregorian_to_ifc(date: datetime) -> IFCDate: + """Convert a Gregorian date to IFC date.""" + year = date.year + doy = day_of_year(date) + leap = is_leap_year(year) + + # Year Day (last day of year) + if doy == (366 if leap else 365): + return IFCDate(year=year, month=None, day=None, special='Year Day', weekday=None) + + # Leap Day (day 169 in leap years) + if leap and doy == 169: + return IFCDate(year=year, month=None, day=None, special='Leap Day', weekday=None) + + # Adjust for leap day + adjusted_day = doy + if leap and doy > 169: + adjusted_day = doy - 1 + + # Calculate month and day + month = (adjusted_day - 1) // 28 + 1 + day = ((adjusted_day - 1) % 28) + 1 + weekday_idx = (day - 1) % 7 + + return IFCDate( + year=year, + month=month, + day=day, + special=None, + weekday=IFC_WEEKDAYS[weekday_idx] + ) + + +def ifc_to_gregorian(year: int, month: Optional[int] = None, day: Optional[int] = None, + special: Optional[str] = None) -> datetime: + """Convert an IFC date to Gregorian date.""" + leap = is_leap_year(year) + + if special == 'year': + doy = 366 if leap else 365 + elif special == 'leap': + if not leap: + raise ValueError(f"{year} is not a leap year") + doy = 169 + else: + doy = (month - 1) * 28 + day + if leap and month > 6: + doy += 1 + + return datetime(year, 1, 1) + timedelta(days=doy - 1) + + +def print_calendar(year: int): + """Print a full IFC calendar for the given year.""" + leap = is_leap_year(year) + + print(f"\n{'=' * 60}") + print(f" INTERNATIONAL FIXED CALENDAR - {year}") + print(f" {'(Leap Year)' if leap else ''}") + print(f"{'=' * 60}\n") + + for month in range(1, 14): + month_name = IFC_MONTHS[month - 1] + marker = " ***" if month == 7 else "" # Highlight Sol + print(f" {month_name}{marker}") + print(" " + "-" * 28) + print(" Sun Mon Tue Wed Thu Fri Sat") + + for week in range(4): + row = " " + for day_in_week in range(7): + day = week * 7 + day_in_week + 1 + row += f"{day:3} " + print(row) + + # Show Leap Day after June + if month == 6 and leap: + print("\n >>> LEAP DAY <<<\n") + + print() + + print(" YEAR DAY") + print(" " + "-" * 28) + print(" Worldwide Holiday - Outside Weekly Cycle\n") + print(f"{'=' * 60}\n") + + +def print_range(start: datetime, end: datetime): + """Print date range conversion.""" + print(f"\n{'Gregorian':<25} {'Fixed Calendar':<30}") + print("-" * 55) + + current = start + while current <= end: + ifc = gregorian_to_ifc(current) + greg_str = current.strftime("%b %d, %Y") + if ifc.special: + ifc_str = f"** {ifc.special} **" + else: + ifc_str = f"{IFC_MONTHS[ifc.month - 1]} {ifc.day}" + print(f"{greg_str:<25} {ifc_str:<30}") + current += timedelta(days=1) + print() + + +def parse_date(date_str: str) -> datetime: + """Parse various date formats.""" + for fmt in ('%Y-%m-%d', '%m/%d/%Y', '%d-%m-%Y', '%Y/%m/%d'): + try: + return datetime.strptime(date_str, fmt) + except ValueError: + continue + raise ValueError(f"Could not parse date: {date_str}") + + +def main(): + parser = argparse.ArgumentParser( + description='International Fixed Calendar Converter', + formatter_class=argparse.RawDescriptionHelpFormatter, + epilog=""" +Examples: + ifc Show today in IFC + ifc 2025-12-25 Convert Gregorian to IFC + ifc --to-gregorian 7 15 2025 Convert Sol 15, 2025 to Gregorian + ifc --range 2025-01-01 2025-01-31 Show date range + ifc --calendar 2025 Print full year calendar + ifc --months List all IFC months + """ + ) + + parser.add_argument('date', nargs='?', help='Gregorian date (YYYY-MM-DD)') + parser.add_argument('--to-gregorian', '-g', nargs=3, metavar=('MONTH', 'DAY', 'YEAR'), + help='Convert IFC to Gregorian (month day year)') + parser.add_argument('--range', '-r', nargs=2, metavar=('START', 'END'), + help='Convert date range') + parser.add_argument('--calendar', '-c', type=int, metavar='YEAR', + help='Print full year calendar') + parser.add_argument('--months', '-m', action='store_true', + help='List all IFC months') + parser.add_argument('--leap-day', '-l', type=int, metavar='YEAR', + help='Show Leap Day for given year') + parser.add_argument('--year-day', '-y', type=int, metavar='YEAR', + help='Show Year Day for given year') + + args = parser.parse_args() + + # List months + if args.months: + print("\nInternational Fixed Calendar Months:") + print("-" * 35) + for i, month in enumerate(IFC_MONTHS, 1): + marker = " (new month)" if month == "Sol" else "" + print(f" {i:2}. {month}{marker}") + print("\nPlus: Leap Day (after June) and Year Day (end of year)") + print() + return + + # Print calendar + if args.calendar: + print_calendar(args.calendar) + return + + # Date range + if args.range: + start = parse_date(args.range[0]) + end = parse_date(args.range[1]) + if (end - start).days > 366: + print("Error: Range limited to 366 days", file=sys.stderr) + sys.exit(1) + print_range(start, end) + return + + # IFC to Gregorian + if args.to_gregorian: + month_str, day_str, year_str = args.to_gregorian + year = int(year_str) + + # Handle special days + if month_str.lower() in ('leap', 'leapday', 'leap-day'): + try: + result = ifc_to_gregorian(year, special='leap') + print(f"\nLeap Day {year} = {result.strftime('%A, %B %d, %Y')}\n") + except ValueError as e: + print(f"Error: {e}", file=sys.stderr) + sys.exit(1) + return + + if month_str.lower() in ('year', 'yearday', 'year-day'): + result = ifc_to_gregorian(year, special='year') + print(f"\nYear Day {year} = {result.strftime('%A, %B %d, %Y')}\n") + return + + # Parse month (name or number) + try: + month = int(month_str) + except ValueError: + month_lower = month_str.lower() + try: + month = next(i for i, m in enumerate(IFC_MONTHS, 1) + if m.lower().startswith(month_lower)) + except StopIteration: + print(f"Error: Unknown month '{month_str}'", file=sys.stderr) + sys.exit(1) + + day = int(day_str) + if not 1 <= day <= 28: + print("Error: Day must be between 1 and 28", file=sys.stderr) + sys.exit(1) + + result = ifc_to_gregorian(year, month, day) + ifc_weekday = IFC_WEEKDAYS[(day - 1) % 7] + print(f"\n{IFC_MONTHS[month-1]} {day}, {year} ({ifc_weekday})") + print(f"= {result.strftime('%A, %B %d, %Y')}\n") + return + + # Leap Day + if args.leap_day: + year = args.leap_day + if not is_leap_year(year): + print(f"Error: {year} is not a leap year", file=sys.stderr) + sys.exit(1) + result = ifc_to_gregorian(year, special='leap') + print(f"\nLeap Day {year} = {result.strftime('%A, %B %d, %Y')}\n") + return + + # Year Day + if args.year_day: + year = args.year_day + result = ifc_to_gregorian(year, special='year') + print(f"\nYear Day {year} = {result.strftime('%A, %B %d, %Y')}\n") + return + + # Convert Gregorian to IFC + if args.date: + date = parse_date(args.date) + else: + date = datetime.now() + + ifc = gregorian_to_ifc(date) + + print(f"\nGregorian: {date.strftime('%A, %B %d, %Y')}") + print(f"Fixed Cal: {ifc}") + + if ifc.special: + print(f" (Outside the weekly cycle)") + print() + + +if __name__ == '__main__': + main() diff --git a/index.html b/index.html new file mode 100644 index 0000000..ac28613 --- /dev/null +++ b/index.html @@ -0,0 +1,876 @@ + + + + + + International Fixed Calendar Converter + + + +
+

International Fixed Calendar

+

13 months × 28 days = Every date falls on the same weekday each year

+ +
+ + + + +
+ + +
+
+
+

Gregorian Calendar

+
+ + +
+ +
+
-
+
+
+
+ +
+ +
+

Fixed Calendar

+
+ + +
+
+ + +
+
+ + +
+
+
-
+
+
+
+
+
+ + +
+
+

Convert Date Range

+
+
+ + +
+
+ + +
+ +
+
+
+
+ + +
+ +
+
+ + + +
+ + +
+
+
+ + +
+
+

About the International Fixed Calendar

+
+
+

13 Equal Months

+

Each month has exactly 28 days (4 complete weeks). The 13th month "Sol" is inserted between June and July.

+
+
+

Year Day

+

The last day of the year (after December 28) is "Year Day" - a worldwide holiday outside the weekly cycle.

+
+
+

Leap Day

+

In leap years, "Leap Day" occurs after June 28 (before Sol 1). It's also outside the weekly cycle.

+
+
+

Perpetual Calendar

+

Every year is identical! The 1st of each month is always Sunday. The 13th is always Friday. Scheduling becomes trivial.

+
+
+ +

The 13 Months

+
+
1 January
+
2 February
+
3 March
+
4 April
+
5 May
+
6 June
+
7 Sol
+
8 July
+
9 August
+
10 September
+
11 October
+
12 November
+
13 December
+
+ +

IFC Weekday Pattern

+

Every month follows the same pattern. Day 1 is always Sunday:

+
+ + + + + + + + + + + + + + +
SunMonTueWedThuFriSat
1234567
891011121314
15161718192021
22232425262728
+
+ +

History

+

+ The International Fixed Calendar was proposed by Moses B. Cotsworth in 1902 and later championed by George Eastman (founder of Kodak). + Eastman used it at Kodak from 1928 to 1989. The League of Nations considered adopting it in the 1920s-30s but faced opposition from religious groups + concerned about the "blank days" disrupting the weekly Sabbath cycle. +

+
+
+
+ + + +