From 46ee143ab1741f263621dd335e1c42175cd87b0e Mon Sep 17 00:00:00 2001 From: Flatlogic Bot Date: Wed, 1 Apr 2026 16:28:08 +0000 Subject: [PATCH] 1.1 --- ai/__pycache__/__init__.cpython-311.pyc | Bin 0 -> 404 bytes ai/__pycache__/local_ai_api.cpython-311.pyc | Bin 0 -> 19874 bytes config/__pycache__/settings.cpython-311.pyc | Bin 5552 -> 5799 bytes config/settings.py | 8 +- core/__pycache__/admin.cpython-311.pyc | Bin 212 -> 872 bytes core/__pycache__/forms.cpython-311.pyc | Bin 0 -> 10629 bytes core/__pycache__/models.cpython-311.pyc | Bin 209 -> 5033 bytes core/__pycache__/tests.cpython-311.pyc | Bin 0 -> 3198 bytes core/__pycache__/urls.cpython-311.pyc | Bin 347 -> 1197 bytes core/__pycache__/utils.cpython-311.pyc | Bin 0 -> 9120 bytes core/__pycache__/views.cpython-311.pyc | Bin 1364 -> 11560 bytes core/admin.py | 10 +- core/forms.py | 142 +++++++++ core/migrations/0001_initial.py | 38 +++ .../__pycache__/0001_initial.cpython-311.pyc | Bin 0 -> 2542 bytes core/models.py | 73 ++++- core/templates/base.html | 63 +++- core/templates/core/index.html | 275 +++++++++--------- core/templates/core/report.html | 67 +++++ core/templates/core/trip_detail.html | 59 ++++ core/templates/core/trip_form.html | 148 ++++++++++ core/templates/core/trip_list.html | 58 ++++ core/tests.py | 39 ++- core/urls.py | 10 +- core/utils.py | 97 ++++++ core/views.py | 153 +++++++++- static/css/custom.css | 132 ++++++++- static/js/mileage_app.js | 81 ++++++ staticfiles/css/custom.css | 137 ++++++++- staticfiles/js/mileage_app.js | 81 ++++++ 30 files changed, 1479 insertions(+), 192 deletions(-) create mode 100644 ai/__pycache__/__init__.cpython-311.pyc create mode 100644 ai/__pycache__/local_ai_api.cpython-311.pyc create mode 100644 core/__pycache__/forms.cpython-311.pyc create mode 100644 core/__pycache__/tests.cpython-311.pyc create mode 100644 core/__pycache__/utils.cpython-311.pyc create mode 100644 core/forms.py create mode 100644 core/migrations/0001_initial.py create mode 100644 core/migrations/__pycache__/0001_initial.cpython-311.pyc create mode 100644 core/templates/core/report.html create mode 100644 core/templates/core/trip_detail.html create mode 100644 core/templates/core/trip_form.html create mode 100644 core/templates/core/trip_list.html create mode 100644 core/utils.py create mode 100644 static/js/mileage_app.js create mode 100644 staticfiles/js/mileage_app.js diff --git a/ai/__pycache__/__init__.cpython-311.pyc b/ai/__pycache__/__init__.cpython-311.pyc new file mode 100644 index 0000000000000000000000000000000000000000..b9450fa186aa18cc132580d58f737c5e3d36d0ca GIT binary patch literal 404 zcma)(y-EW?5XbjEl1NCfwp;Ad+*NiW1XK(OHdbD&HGBm10ep{? za+P3ZC!|Z|E~vG`@S7Q!|IENV_4{4q?P2mPUVZ!s#jnLb$@b7EkBFlJ@rcJVQgIQh zq)1d+q^ec4BE*v`G)V8yE58py^n+tD$nu0f(R>i^^yc zX8rYC4%$tJ5N;SDO;3hlgbG4SVH3Z>rU9*hw#N(FdZJyH&y9k-zNxjVb65eZow51S z*xRb4400-RLWCBMkgQzq_Kua|wS*Jf^YU#7tL{apH#v3$#N7tMGxed?w28n)q A4*&oF literal 0 HcmV?d00001 diff --git a/ai/__pycache__/local_ai_api.cpython-311.pyc b/ai/__pycache__/local_ai_api.cpython-311.pyc new file mode 100644 index 0000000000000000000000000000000000000000..e906db1fa6c4f29b7a64b3f917acffc876b79a07 GIT binary patch literal 19874 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 ePYFsOPU&~-F8=#&9ufBbJ86a@tU literal 0 HcmV?d00001 diff --git a/config/__pycache__/settings.cpython-311.pyc b/config/__pycache__/settings.cpython-311.pyc index d79d6a79890a58443ee5f65d8d558823a19e9aed..1d243ebdd6b42d1a00c5e4b78ee965fe4e97ba7d 100644 GIT binary patch delta 559 zcmdm>yu0Y_b=#DzkZt#pEJpT}I2v3z!v^tx|ZG zF#&Z0F$6?upy{@r_{~;W6HU-2#dhKk>&f{n0_t`t_Gl((r#Q4QplNWNyo^Pi(P{EI z7FAy7D9sd?D7_TsUwz>25HkbuXDc8vogtl}h9OpB@;Y{@$>;g_ z)YgmDFivFZkql-i;sMHQGTvfKt;j4cDb{2v;spxaVkt;0$(a0)Pt0uwkXvj4BpMh# zFtCX*A&48if*&{-xcK^|yQDj1dt^Uw1Ep`UaC~4!QC1`f)V#T#cPb;}n$4p8u1uz8 zK!GB2AaRR1Gq0owWOxzCnMKwh!Ujaxf(Uycaf`LMASbh=$a!+VpdnW#Pz2=5;;)-e z2~J|NVu9K8fJ0z{#YGP38%VU>MGnOe>?~qzUm1_mH)2&IicbUJgC5)(rz za~4<=NMnj|ipgX#7Gr=~+ZW$8;!)hRgfGF)0`xXW?RSuJPv8XdTPJYCq%Ig%Rnc^I!m%<#( zpy{$%gteYyb3ETvM#fc}zw)~>nHmF)DKY^Px0o~YN{UQDTyqd%0U|6xgcXRe0TQ=Z ziwklxONtyO=L;Efr2$14fw=hI=2b$In3UNVSa~}tF0zQ1jS5VrT{{_k=E4h4!3(Or>j2NFU^R3JfULbZ*xx1L-!?5;QVE^v{MuE+zV z%Nra*c>_wGAV?`rt|C#TyXdG=F}^<(Wo*yRHy(dGW6$?yvqn&?&%dIdl#o9LETyzC zrWqQigcD9IQ4CA`3|>z zmpe%6B56ri&H2}cv0K&-4|34e99=9QWT0Cr4`j|_5e9iAQr&=*|3@tz;eHwdOQIN* zURz-$3K@c~6`5h=EXcdItYSX0X;cMO*OBI`WgV4n_X-uIpcKmrnF$5jO+^l>Yah8= zhT^EcHBTjv5(7dB3*rx$cZIPuNPsb2V=NK8h>hJ~?63&p#ZDu}WEp0d#lb+=XJ{{q zbC9~JAdulc+lwIPN>_yBAfvSZgG@0wa_iv+x)SEPC2!B+^2TuASWke^f*gy4lj zf;W4X?Q9^+rYxgO!>FgIhO2RT|J(5W_}+`*yYYkF;m&yN+3>9y^3n2;si(ycg0wHT zLy_WJ^rYzwUBbd3R?;+$u1M%(J(zrL$GB%NIREpyVVcoBM(d{Xa(*GN#a(c}dpKZ_i1ISnXlwc;m$RO**3w_ HS!(|Pa)s-f delta 168 zcmaFCc7-uzIWI340}#~ttjM$n(vLwL7+``jJ_`XE(-~42QW$d>av7r-85vTTf*CZK zUxE~9GTvfMOv%m6^V4Ly#g~$mn3tZfmzKBJiZhlH>PO4oI c2T+U=h>K-`#0O?ZM#dWq3Ky`UA~v8309n{1>i_@% diff --git a/core/__pycache__/forms.cpython-311.pyc b/core/__pycache__/forms.cpython-311.pyc new file mode 100644 index 0000000000000000000000000000000000000000..04c46f8f455ffc02551d41e591e6b3d6ac24d42d GIT binary patch literal 10629 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 delta 164 zcmZ3fevvU@IWI340}#~ttjM$k(vLwL7+``jJ_`XE(-~42QW$d>av7r-85vTTf*CZK zUxE~9GTvg#%}+_qDfZK3y2Y82m6(^Fua}Zk#0-?2yq(3|>KBI%P(f)d0X Xi)Det2WCb_#v2SW7qFosHlPXsp;RN7 diff --git a/core/__pycache__/tests.cpython-311.pyc b/core/__pycache__/tests.cpython-311.pyc new file mode 100644 index 0000000000000000000000000000000000000000..715989846601dfb68a7b4540e5575190e3f99e8d GIT binary patch literal 3198 zcmds3O>7%Q6rT0(*iL}R(8Nt!mWBpd!AVNmrnHa%Z3RVYsy3|>e8AdzCh3N?*UXID z#005_9uPu8sw&b0NJfItsvJ0RMw}2QyINFhrAU=H^_HqV^~8I-u~Wy5sKkx&_}kf; zH*bF4_uku|y1U~9O5oD(=Cv>(f8$HDz`Iu-ehiNl!U)qWQqZ!RhV6hACW|PwL?~<5Y0xR9cB@W7Gl|$Mgru0!lG9Rqdd48B;+>y{gREd*fdGU@8fG)bEC(3 z-YggvH0e1m)iFbI>=yhI7haPpv%o8Mk>A(+DWP-1EaiQ5KH>nDhl2nsgp;hsNH)N< zt0Ws_0l*Lo0)|-#Fv7yHgNWOMdS-0#$(&_h#sR)&I!ymW(@K1mhq5zv^p(R%h zH2a=>s_5q1La5nZg~sjX3tTP>upkSuaGU0)5y(QMDXN}twg_R7RapCWYyGydX#3dD zbTtOD@b?Rv3l1Kes)R@1JTf|33GW|;hZ{LPH)rPgTRmE^yd@KehRmrh>vb z-Kc$Gf#;QUBYDAL8v^gqjo}KGw=IfhKZpA0d*`Q5PMw&Z7HDT+T}X0kPU7Y2k250| zVVRNgg>q4qNBAPomz6C>E(2d=$;k7Oye;^MLfQU^ov+rg<}Zy*6WB?GuZ z)@avPU3Y2!9ooOvlX7|n>OK3Mo_&qpJ&l)kHTnk{slmqX57&0=yk2m2yuQ}Iw>n<$ zA9DJK)>3-4x1LHnsq}i1#CAO(@mTy3!g@PAgmp+8myuN-mw$!E3Rxx#DB_iBi%ZQO ze5w`?fpc0ruiD=}*Oo~Yb$66yZIk;funL}XyX7q!?4kO^8k>`sH5S||>+v=;<%ZBC z_x}Ll%E&3x;>JAJEqi_*d8#fNN}uE0k~-L(E$MT%(3P>MOKwQJcq;;g@L22y=eZKN zwwo}dzT!b_y$0Y?Kg66Yy3xG9jvHYdqfaN1dp#m=>=L%HaR^ySZ^PwIQS=r04L}>6 zYeN(Dfk|gzvUXUSvSfK)=KT&&!E!qx$#`C zV6(D??XFx7gS0;)_TVKBAq*oN0=Pvy4PHPN$P*8+A!h-uw0`S>L>Llg3w8#>A8G64 z35*BB2eD%V;-T4X~v`{Bo|_dVez*n&}p0co{pMknI zWXj5fF7zEY0*8hy=6%ihCpiM$o2PqB-}0|zLc9Y5n7-wA0P8_b(;B3|)?V)sz1ChE lZID-L?e!k%tF_nl$Sw`<;pupTKJZ2!Z8Pp|{~u`D>Zbqz literal 0 HcmV?d00001 diff --git a/core/__pycache__/urls.cpython-311.pyc b/core/__pycache__/urls.cpython-311.pyc index f705988a223abc7b5af333902925bc196186bbf5..10c39b136620a5b5b66a30e19ed5b46ad4737115 100644 GIT binary patch literal 1197 zcmc&yzi-n(6h0>}j(;?i{-9K)Rm2#n>akQ2G*qg})JQBa8KElVo{Fh2iOzOgf{;3P z>_*2-Py|E&2L1wD@=#e~V(J#DTPN=9M1m^JoPB4%?|bjw``+D`O2r~_6b^p4KMREX zQpR{xMnBi98X=zuBTVxM*BnhFtiXz1fft=32M2h`DRJG=dD$s*!!fw&nA~zKZacO{ z3IsI<2(0v7|6C;Go1FQ`m@-<-n8=t|X4XW;Mw3lVWGZO0I}@2{G}zsV%na&mb|N!} zB{n~ixrarzaGTjPohf$z1*t7wDS&;TDxwMuoap zeIu74vl+^q9hJB6Wi{Ew?nO6Kvili8D( zXL z;K9Yy2)1I_N?>aQJ{)XZypCWyhV2Bl^DUQ7UkyG+upYyD0_!8km8kkCs_#V5jG>u8 T^B?eV&YCV9+v*YXvfaM{L%KT1 delta 241 zcmZ3>d7G(zIWI340}#~ttjNp)(vLwL7+{4mKHC5p(-~42QW$d>av7r-85vTTQkZj? za+#x;85x)uQW;ZNQkhd&*RU;PW?)zi#1N3q7{!vp9?YQ0@e(AU$#{#UAh9IlB_ouR zVUVAjdW)e5WD!U*FEKaOPm}c)cS=@bUV6S>X;Dsb5y-S#tYw+0<;6v;lbdMF-&%3mK1uxAbtTAO}@u0Ch&ooiJz%~8w87ZfXV?~l{7g3 diff --git a/core/__pycache__/utils.cpython-311.pyc b/core/__pycache__/utils.cpython-311.pyc new file mode 100644 index 0000000000000000000000000000000000000000..c9d0f0238f2d13ed79be40b7ee48cbdc405df697 GIT binary patch literal 9120 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 literal 0 HcmV?d00001 diff --git a/core/__pycache__/views.cpython-311.pyc b/core/__pycache__/views.cpython-311.pyc index 2f0989c7137f7930a63ea76fcd63516c4974e834..bdd96cb0801a53c1666009080ae308d73703dca1 100644 GIT binary patch literal 11560 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 b!w6_!^CstHQ5jKUV)J`l^-)FwU2Ojg?9wa{ literal 1364 zcmZ`(&1)M+6ra_{dS%J>I!@|TuH%hUh$T{~CE!96oTjQ1+$K)2Q%ZHHP<`ZWm8k%0`!Liw{@%1fY!tdJefM7AQl zBhn19Sa6Ea_&F0f52+#t(zs7E+?3c%aE4Gt31lN`$cK>CaWDY%0l$fN{S4(m*^98b zAQFOZ8L?*M5b8J{)dItqY^-hhUDsF{YgW-QOsAxII>p%0Y9=dd7Ae9|G&s?)vtv@? z*w|s3fmzYCyi6leblsZSXw)-0vAJ^Fb$=8ZkKM6~%pxUIZ^SlnP1|O53-h^$TMxcl z&pj%9yS|;<+IaYARqQc33p$+=>?7eip%aJUUFM01nxMCzWy{3I+3Mq}!>U=li}flaG+QI|spl4T zob~hDF>%eyxOE;8-gFVJ3n+Sj0BfPw>ijEJJ5jZcy4+QlU#Tl6>PkmVchz)jqo<{g zem>D|w6z=i>HYM}h1U1|$#c)H{hoQ2Y2^ko`uNk{{Bl3J(n+Sf$#g%t+)aMbNnYMq_I%*JNq0Rq;#`{q1K zoZG+JUbuEt>cm&O@zr)@^%Rl>0Md)F(RpB8KAS2P06puedS=OZWkMOW!1G(l6GL;A z0R;Uy__hCq=p}LW6JT$Zn>I137G9%QKox%h?+LI$Sdye3inaW&hh|#-_ZofP9$!6l Xr9HlS=yH2}4P;gNa)91vejfh;`i@)& diff --git a/core/admin.py b/core/admin.py index 8c38f3f..9e75f94 100644 --- a/core/admin.py +++ b/core/admin.py @@ -1,3 +1,11 @@ from django.contrib import admin -# Register your models here. +from .models import Trip + + +@admin.register(Trip) +class TripAdmin(admin.ModelAdmin): + list_display = ("date", "trip_type", "start_location", "end_location", "distance_miles", "distance_source", "updated_at") + list_filter = ("trip_type", "distance_source", "date") + search_fields = ("start_location", "end_location", "business_purpose", "notes") + ordering = ("-date", "-start_time", "-created_at") diff --git a/core/forms.py b/core/forms.py new file mode 100644 index 0000000..6c1978c --- /dev/null +++ b/core/forms.py @@ -0,0 +1,142 @@ +from decimal import Decimal + +from django import forms +from django.utils import timezone + +from .models import Trip + + +DATETIME_LOCAL_FORMAT = "%Y-%m-%dT%H:%M" + + +class TripForm(forms.ModelForm): + created_at_override = forms.DateTimeField( + required=False, + label="Custom created timestamp", + input_formats=[DATETIME_LOCAL_FORMAT], + widget=forms.DateTimeInput(attrs={"type": "datetime-local", "class": "form-control", "placeholder": "Leave blank to use now"}, format=DATETIME_LOCAL_FORMAT), + help_text="Optional for retroactive entries. Leave blank to use the current timestamp.", + ) + updated_at_override = forms.DateTimeField( + required=False, + label="Custom updated timestamp", + input_formats=[DATETIME_LOCAL_FORMAT], + widget=forms.DateTimeInput(attrs={"type": "datetime-local", "class": "form-control", "placeholder": "Leave blank to auto-update"}, format=DATETIME_LOCAL_FORMAT), + help_text="Optional. If empty while editing, the app will stamp the current time.", + ) + update_end_odometer_from_map = forms.BooleanField( + required=False, + label="Use Google Maps miles to prefill ending odometer", + help_text="If a starting odometer and route miles are available, this suggests an ending odometer.", + ) + + class Meta: + model = Trip + fields = ["date", "start_time", "end_time", "start_location", "end_location", "business_purpose", "trip_type", "start_odometer", "end_odometer", "distance_miles", "notes"] + widgets = { + "date": forms.DateInput(attrs={"type": "date", "class": "form-control"}), + "start_time": forms.TimeInput(attrs={"type": "time", "class": "form-control"}), + "end_time": forms.TimeInput(attrs={"type": "time", "class": "form-control"}), + "start_location": forms.TextInput(attrs={"class": "form-control", "placeholder": "123 Market St, San Francisco"}), + "end_location": forms.TextInput(attrs={"class": "form-control", "placeholder": "Client office, warehouse, property, etc."}), + "business_purpose": forms.Textarea(attrs={"class": "form-control", "rows": 3, "placeholder": "Meeting with Client A at Property X"}), + "trip_type": forms.Select(attrs={"class": "form-select"}), + "start_odometer": forms.NumberInput(attrs={"class": "form-control", "step": "0.1", "min": "0", "placeholder": "Optional"}), + "end_odometer": forms.NumberInput(attrs={"class": "form-control", "step": "0.1", "min": "0", "placeholder": "Optional"}), + "distance_miles": forms.NumberInput(attrs={"class": "form-control", "step": "0.1", "min": "0", "placeholder": "Auto-filled from Google Maps or manual"}), + "notes": forms.Textarea(attrs={"class": "form-control", "rows": 3, "placeholder": "Client, project, repair details, or extra notes"}), + } + help_texts = { + "business_purpose": "Be specific so the trip is audit-friendly.", + "distance_miles": "Preferred source is odometer when both readings are present; otherwise Google Maps mileage is used.", + } + + def __init__(self, *args, latest_end_odometer=None, **kwargs): + super().__init__(*args, **kwargs) + if not self.instance.pk and latest_end_odometer is not None and self.initial.get("start_odometer") in (None, ""): + self.initial["start_odometer"] = latest_end_odometer + + + def clean_business_purpose(self): + purpose = (self.cleaned_data.get("business_purpose") or "").strip() + if len(purpose) < 8: + raise forms.ValidationError("Please add a more specific business purpose for this trip.") + return purpose + + def clean(self): + cleaned_data = super().clean() + start_odometer = cleaned_data.get("start_odometer") + end_odometer = cleaned_data.get("end_odometer") + distance_miles = cleaned_data.get("distance_miles") + should_prefill_end = cleaned_data.get("update_end_odometer_from_map") + + if start_odometer is not None and end_odometer is not None and end_odometer < start_odometer: + self.add_error("end_odometer", "Ending odometer must be greater than or equal to starting odometer.") + + if should_prefill_end and start_odometer is not None and distance_miles is not None and not end_odometer: + cleaned_data["end_odometer"] = Decimal(start_odometer) + Decimal(distance_miles) + self.cleaned_data["end_odometer"] = cleaned_data["end_odometer"] + + if start_odometer is None and end_odometer is not None: + self.add_error("start_odometer", "Add a starting odometer before storing an ending odometer.") + + if start_odometer is None and end_odometer is None and distance_miles is None: + self.add_error("distance_miles", "Calculate Google Maps mileage or enter odometer readings.") + + return cleaned_data + + def save(self, commit=True): + trip = super().save(commit=False) + now = timezone.now() + created_override = self.cleaned_data.get("created_at_override") + updated_override = self.cleaned_data.get("updated_at_override") + + if trip.pk: + trip.created_at = created_override or trip.created_at + trip.updated_at = updated_override or now + else: + trip.created_at = created_override or now + trip.updated_at = updated_override or trip.created_at + + if commit: + trip.save() + return trip + + +class TripFilterForm(forms.Form): + start_date = forms.DateField(required=False, widget=forms.DateInput(attrs={"type": "date", "class": "form-control"})) + end_date = forms.DateField(required=False, widget=forms.DateInput(attrs={"type": "date", "class": "form-control"})) + month = forms.IntegerField(required=False, min_value=1, max_value=12, widget=forms.NumberInput(attrs={"class": "form-control", "placeholder": "Month"})) + year = forms.IntegerField(required=False, min_value=2000, max_value=2100, widget=forms.NumberInput(attrs={"class": "form-control", "placeholder": "Year"})) + trip_type = forms.ChoiceField(required=False, choices=[("", "All trip types"), *Trip.TripType.choices], widget=forms.Select(attrs={"class": "form-select"})) + + +class ReportFilterForm(forms.Form): + REPORT_CHOICES = [("month", "Monthly report"), ("range", "Custom date range"), ("year", "Annual summary")] + + report_type = forms.ChoiceField(choices=REPORT_CHOICES, required=False, initial="month", widget=forms.Select(attrs={"class": "form-select"})) + month = forms.IntegerField(required=False, min_value=1, max_value=12, widget=forms.NumberInput(attrs={"class": "form-control"})) + year = forms.IntegerField(required=False, min_value=2000, max_value=2100, widget=forms.NumberInput(attrs={"class": "form-control"})) + start_date = forms.DateField(required=False, widget=forms.DateInput(attrs={"type": "date", "class": "form-control"})) + end_date = forms.DateField(required=False, widget=forms.DateInput(attrs={"type": "date", "class": "form-control"})) + + def clean(self): + cleaned = super().clean() + report_type = cleaned.get("report_type") or "month" + year = cleaned.get("year") + month = cleaned.get("month") + start_date = cleaned.get("start_date") + end_date = cleaned.get("end_date") + + if report_type == "month": + if not month or not year: + raise forms.ValidationError("Choose both month and year for the monthly report.") + elif report_type == "range": + if not start_date or not end_date: + raise forms.ValidationError("Choose both start and end dates for a custom range.") + if end_date < start_date: + raise forms.ValidationError("End date must be on or after the start date.") + elif report_type == "year": + if not year: + raise forms.ValidationError("Choose a year for the annual summary.") + return cleaned diff --git a/core/migrations/0001_initial.py b/core/migrations/0001_initial.py new file mode 100644 index 0000000..b73a998 --- /dev/null +++ b/core/migrations/0001_initial.py @@ -0,0 +1,38 @@ +# Generated by Django 5.2.7 on 2026-03-24 11:31 + +import django.utils.timezone +from django.db import migrations, models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ] + + operations = [ + migrations.CreateModel( + name='Trip', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('date', models.DateField()), + ('start_time', models.TimeField()), + ('end_time', models.TimeField()), + ('start_location', models.CharField(max_length=255)), + ('end_location', models.CharField(max_length=255)), + ('business_purpose', models.TextField()), + ('trip_type', models.CharField(choices=[('business', 'Business'), ('personal', 'Personal'), ('commuting', 'Commuting'), ('repair', 'Repair / Maintenance')], max_length=20)), + ('start_odometer', models.DecimalField(blank=True, decimal_places=1, max_digits=10, null=True)), + ('end_odometer', models.DecimalField(blank=True, decimal_places=1, max_digits=10, null=True)), + ('distance_miles', models.DecimalField(blank=True, decimal_places=1, max_digits=8, null=True)), + ('distance_source', models.CharField(choices=[('odometer', 'Odometer'), ('map', 'Google Maps')], default='map', max_length=20)), + ('notes', models.TextField(blank=True)), + ('created_at', models.DateTimeField(default=django.utils.timezone.now)), + ('updated_at', models.DateTimeField(default=django.utils.timezone.now)), + ], + options={ + 'ordering': ['-date', '-start_time', '-created_at'], + }, + ), + ] diff --git a/core/migrations/__pycache__/0001_initial.cpython-311.pyc b/core/migrations/__pycache__/0001_initial.cpython-311.pyc new file mode 100644 index 0000000000000000000000000000000000000000..bb347da696b02e93488ab018aff111934f00ae9b GIT binary patch literal 2542 zcmbtVJy07*6uuuxNJ78~3osavWr1zsfFNM(!f^(R!Li5qmynE#oDc0H4(>;}J7Gh9 zh9X5uccEllkja=LMGA^ksZy?HuF!1dnN)Q0*p-{)?VS#Xz>GbU`+4{F+qdt1`}XbL z&uwjS4zB3>A8M+FyCTQEfY-l@VZ=Y;a^VBkyqgnfTvyAM;;5!!(}->&tl6cet@r6_HeUZGu-Mp( zTNDsq6w6#OByc#q8VLp<0f~ULtVRzJ9`PuI!h4b35b%K4zm^5m;#tN5WEYedL4$+- zsMqghg~7-AWAEvYd;P6}OVqlT*ySMxy#56-+CIRh-LpvqqmJ?DIO?O!A?Z1fl6#%I zkg*r=9!5LmS+)fTWG02W5Wm-bEH^zLYTq3D=~5E#?A?I@NbJ1QViY#?82_fgF(dAsW1Z%9Cnhkd3zl{IXXofVp3 z#(@Eqma4XDVB3}}RjUFqXv_hQva?aaxe#p8Xkb92H=3pltzgSG4Mn5T`%MkdSjp7& zs-qfZ8Y=`U;1LT~6xG^HJ@me3^JnK3)o`$(7$tm=VjKn9Qd%+961J^V(5B6an8?(z zgRKL$Zw}aYgrtR+G{sn>?Fg43U7B3c6d31E5~*d?v1!PtYMMXQzy;W=kR6C9>#BzB z19nW%jziEkt5ylKxgCy(-fM;jRMeHqX6sGUENd9(S8QvPu@S8_bJK$GvQpI?pM2Ob z9c<@#E6e)g&E=I92cR(zs#e${B7h*AgYo9Nf7S6x>m*mIC$0y|GRL3eC7W+qd0)B3seF=`mt;73ed*{~8Mu<;E zw(c~dTrB-#+>H*_lRYn!U8HB^*}B^^vLE_$h!kaFSft0U_1JEYO(X|wNN;Yt+wINO zdM~-Xmx%OHJvl(q`R#Q#ov)=Q-1G#IKCUOvkb%+dMR#DdHjsA*@BoI2WJM!cy_VJ8tWG4Oo;*i} zrgrAsp(%2Gk$h>8PfW7$Jy5tK+-2N`?=fy5fg4E1?LKfjP5MT*&%1pizotmvbgggN z?VBdj6)(M$EWMNabHCqluRUIyglTX-e=5% z=jJwOZ==`z?RnjrhbBAP>?IHlk>~kFgyRPY*Y7`d?jkwqX++X|q`@5*&*H8$^on)7 JIic6%@DD#Z!t?+D literal 0 HcmV?d00001 diff --git a/core/models.py b/core/models.py index 71a8362..04d561d 100644 --- a/core/models.py +++ b/core/models.py @@ -1,3 +1,72 @@ -from django.db import models +from decimal import Decimal -# Create your models here. +from django.core.exceptions import ValidationError +from django.db import models +from django.urls import reverse +from django.utils import timezone + + +class Trip(models.Model): + class TripType(models.TextChoices): + BUSINESS = "business", "Business" + PERSONAL = "personal", "Personal" + COMMUTING = "commuting", "Commuting" + REPAIR = "repair", "Repair / Maintenance" + + class DistanceSource(models.TextChoices): + ODOMETER = "odometer", "Odometer" + MAP = "map", "Google Maps" + + date = models.DateField() + start_time = models.TimeField() + end_time = models.TimeField() + start_location = models.CharField(max_length=255) + end_location = models.CharField(max_length=255) + business_purpose = models.TextField() + trip_type = models.CharField(max_length=20, choices=TripType.choices) + start_odometer = models.DecimalField(max_digits=10, decimal_places=1, null=True, blank=True) + end_odometer = models.DecimalField(max_digits=10, decimal_places=1, null=True, blank=True) + distance_miles = models.DecimalField(max_digits=8, decimal_places=1, null=True, blank=True) + distance_source = models.CharField(max_length=20, choices=DistanceSource.choices, default=DistanceSource.MAP) + notes = models.TextField(blank=True) + created_at = models.DateTimeField(default=timezone.now) + updated_at = models.DateTimeField(default=timezone.now) + + class Meta: + ordering = ["-date", "-start_time", "-created_at"] + + def __str__(self): + return f"{self.date:%Y-%m-%d} · {self.start_location} → {self.end_location}" + + def get_absolute_url(self): + return reverse("trip_detail", args=[self.pk]) + + @property + def miles_display(self): + return self.distance_miles or Decimal("0.0") + + def clean(self): + errors = {} + if self.end_time and self.start_time and self.end_time < self.start_time: + errors["end_time"] = "End time must be after the start time." + + if self.start_odometer is not None and self.end_odometer is not None: + if self.end_odometer < self.start_odometer: + errors["end_odometer"] = "Ending odometer must be greater than or equal to starting odometer." + else: + self.distance_miles = self.end_odometer - self.start_odometer + self.distance_source = self.DistanceSource.ODOMETER + elif self.distance_miles is not None: + if self.distance_miles < 0: + errors["distance_miles"] = "Distance must be zero or greater." + else: + self.distance_source = self.DistanceSource.MAP + else: + errors["distance_miles"] = "Add odometer readings or calculate mileage from Google Maps." + + if errors: + raise ValidationError(errors) + + def save(self, *args, **kwargs): + self.full_clean() + super().save(*args, **kwargs) diff --git a/core/templates/base.html b/core/templates/base.html index 1e7e5fb..22a2804 100644 --- a/core/templates/base.html +++ b/core/templates/base.html @@ -1,25 +1,66 @@ +{% load static %} - - {% block title %}Knowledge Base{% endblock %} - {% if project_description %} - - - - {% endif %} + + {% block title %}{{ meta_title|default:"MileLedger" }}{% endblock %} + {% if project_image_url %} {% endif %} - {% load static %} + + + + {% block head %}{% endblock %} - - {% block content %}{% endblock %} - +
+
+
+
+ +
+ {% if messages %} +
+ {% for message in messages %} + + {% endfor %} +
+ {% endif %} + {% block content %}{% endblock %} +
+
+ + + {% block scripts %}{% endblock %} + diff --git a/core/templates/core/index.html b/core/templates/core/index.html index faec813..9b32db5 100644 --- a/core/templates/core/index.html +++ b/core/templates/core/index.html @@ -1,145 +1,142 @@ {% extends "base.html" %} -{% block title %}{{ project_name }}{% endblock %} - -{% block head %} - - - - -{% endblock %} +{% block title %}{{ meta_title }}{% endblock %} {% block content %} -
-
-

Analyzing your requirements and generating your app…

-
- Loading… +
+
+
+
+
Secure mobile mileage journal
+

Mileage logging that feels ready for tax season, even from your iPhone.

+

Capture retroactive business trips, reuse the last odometer reading, and keep an IRS-style log that is easy to review, export, and print.

+ +
Default next odometer{% if latest_end_odometer %}: {{ latest_end_odometer }} mi{% else %}: not set yet{% endif %}
+
+
+
+
+ Current month + {{ report_month }}/{{ report_year }} +
+
+
+

Business miles

+

{{ month_business|floatformat:1 }}

+ Captured this month +
+
+

Business miles YTD

+

{{ ytd_business|floatformat:1 }}

+ Ready for annual review +
+
+

Business trips this month

+

{{ business_trip_count }}

+ {% if last_trip %}Last trip {{ last_trip.date|date:"M j" }}{% else %}Add your first trip to start tracking{% endif %} +
+
+
+
-

AppWizzy AI is collecting your requirements and applying the first changes.

-

This page will refresh automatically as the plan is implemented.

-

- Runtime: Django {{ django_version }} · Python {{ python_version }} - — UTC {{ current_time|date:"Y-m-d H:i:s" }} -

-
-
- Page updated: {{ current_time|date:"Y-m-d H:i:s" }} (UTC) -
-{% endblock %} \ No newline at end of file + + +
+
+
+
+
+
+
+

Workflow

+

Fast entry, odometer continuity, and report-ready detail.

+
+
+
+
+
01
+

Trip input built for thumbs

+

Large fields, stacked layout, clear inline validation, and past-date support for retroactive mileage.

+
+
+
02
+

Google Maps assist

+

Estimate driving miles from your start and destination, then keep the distance editable if the real trip differed.

+
+
+
03
+

IRS-style reporting

+

Review recent trips, generate report totals, and export CSV for your CPA or year-end archive.

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

Recent entries

+

Your latest mileage activity

+
+ View all trips +
+
+ {% if empty_state %} +
+
+

No trips logged yet

+

Start with a business trip and MileLedger will begin carrying your odometer history forward.

+ Log your first trip +
+ {% else %} +
+ {% for trip in recent_trips %} +
+
+ {{ trip.get_trip_type_display }} + {{ trip.date|date:"M j, Y" }} · {{ trip.start_time|time:"g:i A" }}–{{ trip.end_time|time:"g:i A" }} +
+
+ {{ trip.start_location }} + + {{ trip.end_location }} +
+ +
+ {% endfor %} +
+ {% endif %} +
+
+
+{% endblock %} diff --git a/core/templates/core/report.html b/core/templates/core/report.html new file mode 100644 index 0000000..61e7a7e --- /dev/null +++ b/core/templates/core/report.html @@ -0,0 +1,67 @@ +{% extends "base.html" %} + +{% block title %}{{ meta_title }}{% endblock %} + +{% block content %} +
+
+
+

IRS-style mileage log

+

Reports

+

Monthly, custom-range, and annual summaries with CSV export and print-friendly tables.

+
+
+ Log trip + +
+
+
+
+
+
+
+
{{ form.report_type }}
+
{{ form.month }}
+
{{ form.year }}
+
{{ form.start_date }}
+
{{ form.end_date }}
+
+ {% if form.non_field_errors %}
{{ form.non_field_errors }}
{% endif %} + +
+
+
+

Total business miles

{{ summary.business_miles|floatformat:1 }}

Primary number for deduction review
+

Personal / commuting / repair miles

{{ summary.non_business_miles|floatformat:1 }}

Useful context for year-end records
+

Total trips

{{ summary.trip_count }}

Entries in this report period
+
+
+ {% if trips %} +
+ + + + {% for trip in trips %} + + + + + + + + + + + + + {% endfor %} + +
DateStartEndFromToPurposeTypeStart odoEnd odoMiles
{{ trip.date|date:"Y-m-d" }}{{ trip.start_time|time:"H:i" }}{{ trip.end_time|time:"H:i" }}{{ trip.start_location }}{{ trip.end_location }}{{ trip.business_purpose }}{{ trip.get_trip_type_display }}{{ trip.start_odometer|default:"" }}{{ trip.end_odometer|default:"" }}{{ trip.distance_miles|floatformat:1 }}
+
+ {% else %} +

No trips in this report period

Run a different date range or add a new mileage entry.

+ {% endif %} +
+
+
+{% endblock %} diff --git a/core/templates/core/trip_detail.html b/core/templates/core/trip_detail.html new file mode 100644 index 0000000..a824f0a --- /dev/null +++ b/core/templates/core/trip_detail.html @@ -0,0 +1,59 @@ +{% extends "base.html" %} + +{% block title %}{{ meta_title }}{% endblock %} + +{% block content %} +
+
+
+

Trip detail

+

{{ trip.date|date:"F j, Y" }}

+

{{ trip.start_location }} → {{ trip.end_location }}

+
+ +
+
+
+
+
+
+
+
+
Time{{ trip.start_time|time:"g:i A" }} – {{ trip.end_time|time:"g:i A" }}
+
Trip type{{ trip.get_trip_type_display }}
+
Miles{{ trip.distance_miles|floatformat:1 }} ({{ trip.get_distance_source_display }})
+
Odometer{% if trip.start_odometer or trip.end_odometer %}{{ trip.start_odometer|default:'—' }} → {{ trip.end_odometer|default:'—' }}{% else %}Not recorded{% endif %}
+
+
+

Business purpose

+

{{ trip.business_purpose }}

+

Notes

+

{{ trip.notes|default:"No additional notes recorded." }}

+
+
+
+
+
+

Audit trail

+
+

Created

{{ trip.created_at|date:"M j, Y g:i A" }}

+

Updated

{{ trip.updated_at|date:"M j, Y g:i A" }}

+
+ {% if confirm_delete %} +
+

Delete this trip?

+

This removes the mileage entry from your log.

+
{% csrf_token %}
Cancel
+
+ {% else %} + Delete trip + {% endif %} +
+
+
+
+
+{% endblock %} diff --git a/core/templates/core/trip_form.html b/core/templates/core/trip_form.html new file mode 100644 index 0000000..adc9244 --- /dev/null +++ b/core/templates/core/trip_form.html @@ -0,0 +1,148 @@ +{% extends "base.html" %} +{% load static %} + +{% block title %}{{ meta_title }}{% endblock %} + +{% block content %} +
+
+
+

Trip workflow

+

{{ page_heading }}

+

Capture the route, odometer readings, and audit-ready timestamps in one place.

+
+
+
+ +
+
+
+
+
+
+ {% csrf_token %} + {% if form.non_field_errors %} +
{{ form.non_field_errors }}
+ {% endif %} +
+
+ + {{ form.date }} +
Past dates are supported for retroactive entries.
+ {% for error in form.date.errors %}
{{ error }}
{% endfor %} +
+
+ + {{ form.start_time }} + {% for error in form.start_time.errors %}
{{ error }}
{% endfor %} +
+
+ + {{ form.end_time }} + {% for error in form.end_time.errors %}
{{ error }}
{% endfor %} +
+
+ + {{ form.start_location }} + {% for error in form.start_location.errors %}
{{ error }}
{% endfor %} +
+
+ + {{ form.end_location }} + {% for error in form.end_location.errors %}
{{ error }}
{% endfor %} +
+
+ + {{ form.business_purpose }} +
{{ form.business_purpose.help_text }}
+ {% for error in form.business_purpose.errors %}
{{ error }}
{% endfor %} +
+
+ + {{ form.trip_type }} + {% for error in form.trip_type.errors %}
{{ error }}
{% endfor %} +
+
+ + {{ form.start_odometer }} +
{% if latest_end_odometer %}Suggested from your most recent ending odometer: {{ latest_end_odometer }} miles.{% else %}Optional, but recommended for audit-ready logs.{% endif %}
+ {% for error in form.start_odometer.errors %}
{{ error }}
{% endfor %} +
+
+ + {{ form.end_odometer }} + {% for error in form.end_odometer.errors %}
{{ error }}
{% endfor %} +
+
+ + {{ form.distance_miles }} +
{{ form.distance_miles.help_text }}
+ {% for error in form.distance_miles.errors %}
{{ error }}
{% endfor %} +
+
+
+ +
Enter both locations to calculate route mileage.
+
+
+
+
+ {{ form.update_end_odometer_from_map }} + +
+
{{ form.update_end_odometer_from_map.help_text }}
+
+
+ + {{ form.notes }} + {% for error in form.notes.errors %}
{{ error }}
{% endfor %} +
+
+
+
+
+

Audit trail

+

Timestamp controls

+
+
+
+
+ + {{ form.created_at_override }} +
{{ form.created_at_override.help_text }}
+ {% for error in form.created_at_override.errors %}
{{ error }}
{% endfor %} +
+
+ + {{ form.updated_at_override }} +
{{ form.updated_at_override.help_text }}
+ {% for error in form.updated_at_override.errors %}
{{ error }}
{% endfor %} +
+
+ {% if trip %}

Current stored timestamps: created {{ trip.created_at|date:"M j, Y g:i A" }} · updated {{ trip.updated_at|date:"M j, Y g:i A" }}

{% endif %} +
+
+ + Back to trip list +
+
+
+
+
+
+

How mileage is chosen

+
+

1. Odometer first

If both start and end odometer readings are present, MileLedger saves that difference as the primary trip distance.

+

2. Google Maps fallback

If odometer readings are incomplete, the distance field stores the Google Maps estimate instead.

+

3. Human review stays in control

You can edit route miles, adjust timestamps, and confirm the ending odometer before saving.

+
+
+
+
+
+
+{% endblock %} + +{% block scripts %} + +{% endblock %} diff --git a/core/templates/core/trip_list.html b/core/templates/core/trip_list.html new file mode 100644 index 0000000..74b2593 --- /dev/null +++ b/core/templates/core/trip_list.html @@ -0,0 +1,58 @@ +{% extends "base.html" %} + +{% block title %}{{ meta_title }}{% endblock %} + +{% block content %} +
+
+
+

History

+

Trip log

+

Filter past mileage entries by date range, tax period, or trip type.

+
+ Add another trip +
+
+
+
+
+
+
{{ form.start_date }}
+
{{ form.end_date }}
+
{{ form.month }}
+
{{ form.year }}
+
{{ form.trip_type }}
+
Reset
+
+
+
+

Total business miles

{{ summary.business_miles|floatformat:1 }}

Within the filtered period
+

Personal + commuting + repair miles

{{ summary.non_business_miles|floatformat:1 }}

Included for full context
+

Total trips

{{ summary.trip_count }}

Entries matched by the current filters
+
+
+ {% if trips %} +
+ + + + {% for trip in trips %} + + + + + + + + + {% endfor %} + +
DateRouteTypeMilesSource
{{ trip.date|date:"M j, Y" }}
{{ trip.start_time|time:"g:i A" }}–{{ trip.end_time|time:"g:i A" }}
{{ trip.start_location }}
to {{ trip.end_location }}
{{ trip.get_trip_type_display }}{{ trip.distance_miles|floatformat:1 }}{{ trip.get_distance_source_display }}Details
+
+ {% else %} +

No trips match those filters

Adjust the date range or log a new trip to populate your history.

Log a trip
+ {% endif %} +
+
+
+{% endblock %} diff --git a/core/tests.py b/core/tests.py index 7ce503c..a820138 100644 --- a/core/tests.py +++ b/core/tests.py @@ -1,3 +1,38 @@ -from django.test import TestCase +from decimal import Decimal -# Create your tests here. +from django.test import TestCase +from django.urls import reverse +from django.utils import timezone + +from .models import Trip + + +class TripWorkflowTests(TestCase): + def setUp(self): + Trip.objects.create( + date=timezone.localdate(), + start_time="09:00", + end_time="10:00", + start_location="Office", + end_location="Client Site", + business_purpose="Meeting with client about quarterly roadmap", + trip_type=Trip.TripType.BUSINESS, + start_odometer=Decimal("100.0"), + end_odometer=Decimal("112.5"), + ) + + def test_homepage_loads(self): + response = self.client.get(reverse("home")) + self.assertEqual(response.status_code, 200) + self.assertContains(response, "Mileage logging that feels ready for tax season") + + def test_trip_list_loads(self): + response = self.client.get(reverse("trip_list")) + self.assertEqual(response.status_code, 200) + self.assertContains(response, "Client Site") + + def test_report_csv_exports(self): + response = self.client.get(reverse("report_export_csv"), {"report_type": "year", "year": timezone.localdate().year}) + self.assertEqual(response.status_code, 200) + self.assertEqual(response["Content-Type"], "text/csv") + self.assertIn("Miles for this trip", response.content.decode("utf-8")) diff --git a/core/urls.py b/core/urls.py index 6299e3d..2d591d9 100644 --- a/core/urls.py +++ b/core/urls.py @@ -1,7 +1,15 @@ from django.urls import path -from .views import home +from .views import distance_estimate, home, report_export_csv, report_view, trip_create, trip_delete, trip_detail, trip_list, trip_update urlpatterns = [ path("", home, name="home"), + path("trips/", trip_list, name="trip_list"), + path("trips/new/", trip_create, name="trip_create"), + path("trips//", trip_detail, name="trip_detail"), + path("trips//edit/", trip_update, name="trip_update"), + path("trips//delete/", trip_delete, name="trip_delete"), + path("reports/", report_view, name="report_view"), + path("reports/export.csv", report_export_csv, name="report_export_csv"), + path("distance/estimate/", distance_estimate, name="distance_estimate"), ] diff --git a/core/utils.py b/core/utils.py new file mode 100644 index 0000000..64f419e --- /dev/null +++ b/core/utils.py @@ -0,0 +1,97 @@ +import csv +import json +import os +from dataclasses import dataclass +from decimal import Decimal, ROUND_HALF_UP +from io import StringIO +from urllib.error import HTTPError, URLError +from urllib.parse import urlencode +from urllib.request import urlopen + +from django.db.models import DecimalField, Q, Sum, Value +from django.db.models.functions import Coalesce, ExtractMonth, ExtractYear + +from .models import Trip + + +@dataclass +class DistanceResult: + ok: bool + miles: Decimal | None = None + message: str = "" + + +def calculate_google_maps_distance(start_location: str, end_location: str) -> DistanceResult: + api_key = os.getenv("GOOGLE_MAPS_API_KEY", "").strip() + if not api_key: + return DistanceResult(ok=False, message="Google Maps API key is not configured yet. Add GOOGLE_MAPS_API_KEY to enable route mileage.") + + params = urlencode({"origins": start_location, "destinations": end_location, "mode": "driving", "units": "imperial", "key": api_key}) + url = f"https://maps.googleapis.com/maps/api/distancematrix/json?{params}" + + try: + with urlopen(url, timeout=12) as response: + payload = json.loads(response.read().decode("utf-8")) + except (HTTPError, URLError, TimeoutError): + return DistanceResult(ok=False, message="We could not reach Google Maps right now. Please try again or enter miles manually.") + + if payload.get("status") != "OK": + return DistanceResult(ok=False, message="Google Maps could not validate that route. Please refine both addresses.") + + rows = payload.get("rows") or [] + elements = (rows[0].get("elements") if rows else []) or [] + element = elements[0] if elements else {} + if element.get("status") != "OK": + return DistanceResult(ok=False, message="Google Maps could not find that route. Try a fuller street address or city/state.") + + meters = element.get("distance", {}).get("value") + if meters is None: + return DistanceResult(ok=False, message="Google Maps did not return a distance for that route.") + + miles = (Decimal(str(meters)) / Decimal("1609.344")).quantize(Decimal("0.1"), rounding=ROUND_HALF_UP) + return DistanceResult(ok=True, miles=miles, message="Driving mileage calculated from Google Maps.") + + +def apply_trip_filters(queryset, data): + if data.get("start_date"): + queryset = queryset.filter(date__gte=data["start_date"]) + if data.get("end_date"): + queryset = queryset.filter(date__lte=data["end_date"]) + if data.get("month"): + queryset = queryset.annotate(filter_month=ExtractMonth("date")).filter(filter_month=data["month"]) + if data.get("year"): + queryset = queryset.annotate(filter_year=ExtractYear("date")).filter(filter_year=data["year"]) + if data.get("trip_type"): + queryset = queryset.filter(trip_type=data["trip_type"]) + return queryset + + +def report_queryset(cleaned_data): + queryset = Trip.objects.all() + report_type = cleaned_data.get("report_type") + if report_type == "month": + queryset = queryset.filter(date__month=cleaned_data["month"], date__year=cleaned_data["year"]) + elif report_type == "range": + queryset = queryset.filter(date__range=(cleaned_data["start_date"], cleaned_data["end_date"])) + elif report_type == "year": + queryset = queryset.filter(date__year=cleaned_data["year"]) + return queryset.order_by("date", "start_time", "created_at") + + +def summarize_trips(queryset): + aggregates = queryset.aggregate( + business_miles=Coalesce(Sum("distance_miles", filter=Q(trip_type=Trip.TripType.BUSINESS)), Value(Decimal("0.0")), output_field=DecimalField(max_digits=10, decimal_places=1)), + non_business_miles=Coalesce(Sum("distance_miles", filter=~Q(trip_type=Trip.TripType.BUSINESS)), Value(Decimal("0.0")), output_field=DecimalField(max_digits=10, decimal_places=1)), + total_miles=Coalesce(Sum("distance_miles"), Value(Decimal("0.0")), output_field=DecimalField(max_digits=10, decimal_places=1)), + ) + aggregates["trip_count"] = queryset.count() + return aggregates + + +def export_trips_csv(trips): + buffer = StringIO() + writer = csv.writer(buffer) + writer.writerow(["Date of trip", "Start time", "End time", "Starting location", "Destination", "Business purpose", "Trip type", "Starting odometer", "Ending odometer", "Miles for this trip", "Distance source", "Notes", "Created at", "Updated at"]) + for trip in trips: + writer.writerow([trip.date, trip.start_time, trip.end_time, trip.start_location, trip.end_location, trip.business_purpose, trip.get_trip_type_display(), trip.start_odometer or "", trip.end_odometer or "", trip.distance_miles or "", trip.get_distance_source_display(), trip.notes, trip.created_at, trip.updated_at]) + return buffer.getvalue() diff --git a/core/views.py b/core/views.py index c9aed12..eff7559 100644 --- a/core/views.py +++ b/core/views.py @@ -1,25 +1,146 @@ -import os -import platform +from decimal import Decimal -from django import get_version as django_version -from django.shortcuts import render +from django.contrib import messages +from django.db.models import Sum +from django.http import HttpResponse, JsonResponse +from django.shortcuts import get_object_or_404, redirect, render from django.utils import timezone +from django.views.decorators.http import require_GET, require_http_methods, require_POST + +from .forms import ReportFilterForm, TripFilterForm, TripForm +from .models import Trip +from .utils import apply_trip_filters, calculate_google_maps_distance, export_trips_csv, report_queryset, summarize_trips + + +DEFAULT_META_DESCRIPTION = "Mobile-friendly mileage logging with odometer history, Google Maps assist, and IRS-style reporting." + + +def latest_trip_with_odometer(): + return Trip.objects.exclude(end_odometer__isnull=True).order_by("-date", "-end_time", "-created_at").first() def home(request): - """Render the landing screen with loader and environment details.""" - host_name = request.get_host().lower() - agent_brand = "AppWizzy" if host_name == "appwizzy.com" else "Flatlogic" - now = timezone.now() + today = timezone.localdate() + current_month = Trip.objects.filter(date__year=today.year, date__month=today.month) + ytd = Trip.objects.filter(date__year=today.year) + recent_trips = Trip.objects.all()[:5] + + month_business = current_month.filter(trip_type=Trip.TripType.BUSINESS).aggregate(total=Sum("distance_miles"))["total"] or Decimal("0.0") + ytd_business = ytd.filter(trip_type=Trip.TripType.BUSINESS).aggregate(total=Sum("distance_miles"))["total"] or Decimal("0.0") + business_trip_count = current_month.filter(trip_type=Trip.TripType.BUSINESS).count() + last_trip = Trip.objects.order_by("-date", "-end_time", "-created_at").first() + latest_odo = latest_trip_with_odometer() context = { - "project_name": "New Style", - "agent_brand": agent_brand, - "django_version": django_version(), - "python_version": platform.python_version(), - "current_time": now, - "host_name": host_name, - "project_description": os.getenv("PROJECT_DESCRIPTION", ""), - "project_image_url": os.getenv("PROJECT_IMAGE_URL", ""), + "project_name": "MileLedger", + "meta_title": "MileLedger | IRS-friendly mileage tracking for mobile", + "meta_description": DEFAULT_META_DESCRIPTION, + "month_business": month_business, + "ytd_business": ytd_business, + "business_trip_count": business_trip_count, + "recent_trips": recent_trips, + "last_trip": last_trip, + "latest_end_odometer": latest_odo.end_odometer if latest_odo else None, + "report_month": today.month, + "report_year": today.year, + "empty_state": not Trip.objects.exists(), } return render(request, "core/index.html", context) + + +@require_http_methods(["GET", "POST"]) +def trip_create(request): + latest_odo = latest_trip_with_odometer() + latest_end_odometer = latest_odo.end_odometer if latest_odo else None + if request.method == "POST": + form = TripForm(request.POST, latest_end_odometer=latest_end_odometer) + if form.is_valid(): + trip = form.save() + messages.success(request, "Trip saved. Your mileage log is updated and ready for review.") + return redirect("trip_detail", pk=trip.pk) + else: + now = timezone.localtime(timezone.now()) + form = TripForm(initial={"date": timezone.localdate(), "start_time": now.strftime("%H:%M"), "end_time": now.strftime("%H:%M"), "trip_type": Trip.TripType.BUSINESS}, latest_end_odometer=latest_end_odometer) + + return render(request, "core/trip_form.html", {"form": form, "page_heading": "Add a mileage entry", "submit_label": "Save trip", "meta_title": "Log a trip | MileLedger", "meta_description": DEFAULT_META_DESCRIPTION, "latest_end_odometer": latest_end_odometer, "trip": None}) + + +@require_http_methods(["GET", "POST"]) +def trip_update(request, pk): + trip = get_object_or_404(Trip, pk=pk) + if request.method == "POST": + form = TripForm(request.POST, instance=trip) + if form.is_valid(): + trip = form.save() + messages.success(request, "Trip updated. Your mileage history now reflects the latest details.") + return redirect("trip_detail", pk=trip.pk) + else: + form = TripForm(instance=trip) + + return render(request, "core/trip_form.html", {"form": form, "page_heading": "Update trip details", "submit_label": "Save changes", "meta_title": "Edit trip | MileLedger", "meta_description": DEFAULT_META_DESCRIPTION, "trip": trip, "latest_end_odometer": None}) + + +@require_GET +def trip_list(request): + form = TripFilterForm(request.GET or None) + trips = Trip.objects.all() + if form.is_valid(): + trips = apply_trip_filters(trips, form.cleaned_data) + context = {"form": form, "trips": trips[:100], "summary": summarize_trips(trips), "meta_title": "Trip history | MileLedger", "meta_description": "Review, filter, and audit mileage entries."} + return render(request, "core/trip_list.html", context) + + +@require_GET +def trip_detail(request, pk): + trip = get_object_or_404(Trip, pk=pk) + return render(request, "core/trip_detail.html", {"trip": trip, "meta_title": f"Trip on {trip.date:%b %d, %Y} | MileLedger", "meta_description": "Trip detail and audit-ready timestamps."}) + + +@require_http_methods(["GET", "POST"]) +def trip_delete(request, pk): + trip = get_object_or_404(Trip, pk=pk) + if request.method == "POST": + trip.delete() + messages.success(request, "Trip deleted.") + return redirect("trip_list") + return render(request, "core/trip_detail.html", {"trip": trip, "confirm_delete": True, "meta_title": "Delete trip | MileLedger", "meta_description": "Confirm trip deletion."}) + + +@require_GET +def report_view(request): + today = timezone.localdate() + initial = {"report_type": "month", "month": today.month, "year": today.year} + form = ReportFilterForm(request.GET or initial) + trips = Trip.objects.none() + summary = {"business_miles": Decimal("0.0"), "non_business_miles": Decimal("0.0"), "total_miles": Decimal("0.0"), "trip_count": 0} + if form.is_valid(): + trips = report_queryset(form.cleaned_data) + summary = summarize_trips(trips) + return render(request, "core/report.html", {"form": form, "trips": trips, "summary": summary, "meta_title": "IRS-style mileage report | MileLedger", "meta_description": "Generate monthly, annual, or custom mileage reports and export them to CSV."}) + + +@require_GET +def report_export_csv(request): + today = timezone.localdate() + initial = {"report_type": "month", "month": today.month, "year": today.year} + form = ReportFilterForm(request.GET or initial) + if not form.is_valid(): + messages.error(request, "Choose a valid report filter before exporting.") + return redirect("report_view") + + trips = report_queryset(form.cleaned_data) + response = HttpResponse(export_trips_csv(trips), content_type="text/csv") + response["Content-Disposition"] = 'attachment; filename="mileage-report.csv"' + return response + + +@require_POST +def distance_estimate(request): + start_location = (request.POST.get("start_location") or "").strip() + end_location = (request.POST.get("end_location") or "").strip() + if not start_location or not end_location: + return JsonResponse({"ok": False, "message": "Enter both a start and destination first."}, status=400) + + result = calculate_google_maps_distance(start_location, end_location) + status = 200 if result.ok else 422 + return JsonResponse({"ok": result.ok, "miles": float(result.miles) if result.miles is not None else None, "message": result.message}, status=status) diff --git a/static/css/custom.css b/static/css/custom.css index 925f6ed..8633937 100644 --- a/static/css/custom.css +++ b/static/css/custom.css @@ -1,4 +1,130 @@ -/* Custom styles for the application */ -body { - font-family: system-ui, -apple-system, sans-serif; + +:root { + --app-bg: #f6f2ea; + --app-surface: rgba(255, 252, 248, 0.8); + --app-surface-strong: #fffaf3; + --app-border: rgba(17, 24, 39, 0.1); + --app-text: #18212b; + --app-muted: #5f6875; + --app-primary: #0e776f; + --app-primary-dark: #0a5d57; + --app-secondary: #152033; + --app-accent: #f07a56; + --app-highlight: #f2c66c; + --app-shadow: 0 28px 60px rgba(17, 24, 39, 0.12); + --app-radius-xl: 28px; + --app-radius-lg: 22px; + --app-radius-md: 16px; +} +* { box-sizing: border-box; } +html { scroll-behavior: smooth; } +body { + margin: 0; + min-height: 100vh; + font-family: 'Inter', system-ui, -apple-system, BlinkMacSystemFont, sans-serif; + color: var(--app-text); + background: radial-gradient(circle at top left, rgba(240, 122, 86, 0.15), transparent 34%), radial-gradient(circle at 85% 15%, rgba(14, 119, 111, 0.18), transparent 24%), linear-gradient(180deg, #fcfaf6 0%, var(--app-bg) 100%); +} +h1,h2,h3,h4,h5,h6,.navbar-brand,.btn { font-family: 'Manrope', 'Inter', sans-serif; } +a { color: var(--app-primary); text-decoration: none; } +a:hover { color: var(--app-primary-dark); } +.site-shell { position: relative; overflow: hidden; } +.hero-grid { + position: fixed; inset: 0; + background-image: linear-gradient(rgba(17, 24, 39, 0.03) 1px, transparent 1px), linear-gradient(90deg, rgba(17, 24, 39, 0.03) 1px, transparent 1px); + background-size: 42px 42px; mask-image: linear-gradient(180deg, rgba(0, 0, 0, 0.35), transparent 75%); + pointer-events: none; z-index: -2; +} +.hero-orb { position: fixed; border-radius: 50%; pointer-events: none; z-index: -1; } +.orb-1 { width: 320px; height: 320px; top: -96px; right: -90px; background: radial-gradient(circle, rgba(240, 122, 86, 0.28), rgba(240, 122, 86, 0) 68%); } +.orb-2 { width: 400px; height: 400px; bottom: -160px; left: -120px; background: radial-gradient(circle, rgba(14, 119, 111, 0.18), rgba(14, 119, 111, 0) 70%); } +.site-header { padding: 1rem 0; backdrop-filter: blur(20px); background: rgba(250, 245, 238, 0.72); border-bottom: 1px solid rgba(17, 24, 39, 0.05); } +.app-navbar { padding: 0; } +.brand-lockup { display: inline-flex; align-items: center; gap: 0.85rem; } +.brand-badge { width: 42px; height: 42px; border-radius: 14px; display: inline-flex; align-items: center; justify-content: center; color: #fff; font-weight: 800; background: linear-gradient(135deg, var(--app-primary), #18a298); box-shadow: 0 16px 34px rgba(14, 119, 111, 0.22); } +.brand-name,.brand-tag { display: block; line-height: 1.05; } +.brand-name { font-size: 1rem; font-weight: 800; color: var(--app-secondary); } +.brand-tag { font-size: 0.75rem; color: var(--app-muted); letter-spacing: 0.04em; text-transform: uppercase; margin-top: 0.2rem; } +.nav-link { color: var(--app-muted); font-weight: 600; } +.nav-link:hover,.nav-link:focus { color: var(--app-secondary); } +.nav-toggle { border: 1px solid rgba(17, 24, 39, 0.08); border-radius: 14px; } +.hero-section,.page-hero-sm,.section-shell { position: relative; } +.hero-section { padding: 4.5rem 0 2rem; } +.page-hero-sm { padding: 3rem 0 1.25rem; } +.section-shell { padding: 1.5rem 0 3rem; } +.section-tight { padding-top: 0.5rem; } +.eyebrow,.section-kicker { display: inline-block; margin-bottom: 1rem; padding: 0.45rem 0.8rem; border-radius: 999px; color: var(--app-primary-dark); background: rgba(14, 119, 111, 0.12); font-size: 0.78rem; font-weight: 700; letter-spacing: 0.08em; text-transform: uppercase; } +.hero-title,.section-title { font-size: clamp(2.4rem, 4vw, 4.7rem); line-height: 0.98; letter-spacing: -0.04em; color: var(--app-secondary); margin-bottom: 1rem; } +.page-hero-sm .section-title,.h4.section-title { font-size: clamp(2rem, 3vw, 3rem); } +.hero-copy,.section-subtitle { max-width: 46rem; font-size: 1.05rem; color: var(--app-muted); line-height: 1.75; } +.hero-actions,.form-actions { margin-top: 2rem; } +.hero-note,.audit-caption,.table-muted,.field-help,.distance-status { color: var(--app-muted); font-size: 0.92rem; line-height: 1.55; } +.hero-note { margin-top: 1.15rem; } +.glass-panel,.metric-card,.feature-card,.action-card,.side-card,.trip-stream-item,.empty-state-card,.panel-solid { background: linear-gradient(180deg, rgba(255, 255, 255, 0.85), rgba(255, 250, 244, 0.78)); border: 1px solid var(--app-border); box-shadow: var(--app-shadow); border-radius: var(--app-radius-xl); } +.glass-panel { padding: clamp(1.25rem, 3vw, 2rem); backdrop-filter: blur(18px); } +.showcase-top,.section-heading-wrap,.trip-stream-footer,.distance-action-bar { display: flex; align-items: center; justify-content: space-between; gap: 1rem; flex-wrap: wrap; } +.status-pill,.trip-badge { display: inline-flex; align-items: center; gap: 0.4rem; padding: 0.4rem 0.8rem; border-radius: 999px; background: rgba(21, 32, 51, 0.08); color: var(--app-secondary); font-size: 0.8rem; font-weight: 700; } +.metric-stack,.side-stack,.action-list,.feature-grid,.trip-stream { display: grid; gap: 1rem; } +.metric-card,.feature-card,.side-card,.panel-solid { padding: 1.35rem; } +.metric-card h2 { font-size: clamp(2rem, 3.8vw, 3rem); line-height: 1; margin: 0.5rem 0; } +.metric-card p,.metric-card span,.feature-card p,.side-card p,.action-card span,.trip-stream-item p,.delete-box p { margin: 0; color: var(--app-muted); } +.metric-card p,.feature-card h3,.side-card h3,.action-card strong,.empty-state-card h3,.delete-box h3 { color: var(--app-secondary); } +.accent-card { background: linear-gradient(135deg, rgba(240, 122, 86, 0.16), rgba(242, 198, 108, 0.16)); } +.panel-solid { background: linear-gradient(180deg, #fffaf3, #fffdfa); } +.feature-grid { grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); margin-top: 1.5rem; } +.feature-icon { width: 42px; height: 42px; border-radius: 14px; display: inline-flex; align-items: center; justify-content: center; margin-bottom: 1rem; font-size: 0.82rem; font-weight: 800; color: #fff; background: linear-gradient(135deg, var(--app-secondary), #334155); } +.action-card { display: block; padding: 1.1rem 1.15rem; transition: transform 0.2s ease, border-color 0.2s ease; } +.action-card:hover { transform: translateY(-2px); border-color: rgba(14, 119, 111, 0.3); } +.trip-stream-item { padding: 1.2rem; } +.trip-stream-meta,.trip-stream-route,.trip-stream-footer { display: flex; gap: 0.85rem; align-items: center; justify-content: space-between; flex-wrap: wrap; } +.trip-stream-route { margin: 0.9rem 0; font-size: 1rem; } +.route-arrow { color: var(--app-accent); font-weight: 700; } +.empty-state-card { position: relative; text-align: center; padding: clamp(2rem, 5vw, 3rem); } +.empty-orb { width: 92px; height: 92px; margin: 0 auto 1rem; border-radius: 28px; background: linear-gradient(135deg, rgba(14, 119, 111, 0.18), rgba(240, 122, 86, 0.2)); } +.compact-empty { text-align: left; } +.form-label { font-weight: 700; color: var(--app-secondary); margin-bottom: 0.5rem; } +.form-control,.form-select { min-height: 3.2rem; border-radius: 16px; border: 1px solid rgba(17, 24, 39, 0.1); background: rgba(255, 255, 255, 0.94); padding: 0.85rem 1rem; color: var(--app-text); } +textarea.form-control { min-height: 8.5rem; } +.form-control:focus,.form-select:focus,.form-check-input:focus,.btn:focus { border-color: rgba(14, 119, 111, 0.52); box-shadow: 0 0 0 0.22rem rgba(14, 119, 111, 0.15); } +.field-error { color: #b42318; font-size: 0.9rem; margin-top: 0.35rem; } +.app-check { display: flex; align-items: center; gap: 0.7rem; } +.form-check-input { width: 1.15rem; height: 1.15rem; margin-top: 0; } +.form-check-input:checked { background-color: var(--app-primary); border-color: var(--app-primary); } +.distance-status { padding: 0.85rem 1rem; border-radius: 16px; background: rgba(21, 32, 51, 0.06); } +.distance-status.is-success { color: var(--app-primary-dark); background: rgba(14, 119, 111, 0.12); } +.distance-status.is-error { color: #b42318; background: rgba(212, 59, 48, 0.1); } +.timestamp-panel,.delete-box { padding: 1.2rem; border-radius: 22px; background: rgba(21, 32, 51, 0.04); border: 1px solid rgba(17, 24, 39, 0.06); } +.detail-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); gap: 1rem; } +.detail-grid article,.detail-copy p { line-height: 1.65; } +.detail-grid span { display: block; color: var(--app-muted); font-size: 0.88rem; margin-bottom: 0.35rem; } +.detail-grid strong,.detail-copy h2,.delete-box h3 { color: var(--app-secondary); } +.app-btn-primary,.app-btn-secondary { min-height: 3rem; padding: 0.85rem 1.25rem; border-radius: 999px; font-weight: 800; letter-spacing: -0.01em; } +.app-btn-primary { border: none; color: #fff; background: linear-gradient(135deg, var(--app-primary), #18a298); box-shadow: 0 16px 32px rgba(14, 119, 111, 0.22); } +.app-btn-primary:hover,.app-btn-primary:focus { color: #fff; background: linear-gradient(135deg, var(--app-primary-dark), #0e8c84); } +.app-btn-secondary { color: var(--app-secondary); border-color: rgba(21, 32, 51, 0.14); background: rgba(255, 255, 255, 0.72); } +.app-btn-secondary:hover,.app-btn-secondary:focus { color: var(--app-secondary); border-color: rgba(14, 119, 111, 0.32); background: rgba(255, 255, 255, 0.9); } +.app-alert { border-radius: 18px; border: 1px solid rgba(17, 24, 39, 0.06); } +.section-link { color: var(--app-secondary); font-weight: 700; } +.table-panel { overflow: hidden; } +.app-table { --bs-table-bg: transparent; --bs-table-border-color: rgba(17, 24, 39, 0.08); } +.app-table thead th { font-size: 0.78rem; text-transform: uppercase; letter-spacing: 0.08em; color: var(--app-muted); border-bottom-width: 1px; padding-top: 1.1rem; padding-bottom: 1.1rem; } +.app-table tbody td { padding-top: 1rem; padding-bottom: 1rem; color: var(--app-text); } +.no-print { display: initial; } +@media (max-width: 991.98px) { + .hero-section { padding-top: 3rem; } + .hero-title,.section-title { max-width: 16ch; } + .site-header { padding: 0.85rem 0; } +} +@media (max-width: 767.98px) { + .hero-section,.page-hero-sm { padding-top: 2rem; } + .glass-panel,.metric-card,.feature-card,.side-card,.trip-stream-item,.panel-solid { border-radius: 22px; } + .distance-action-bar,.section-heading-wrap,.trip-stream-footer,.trip-stream-route,.trip-stream-meta { align-items: flex-start; flex-direction: column; } + .table-responsive { border-radius: 22px; } +} +@media print { + body { background: #fff; } + .site-header,.hero-orb,.hero-grid,.no-print,.app-alert { display: none !important; } + .glass-panel,.panel-solid,.metric-card { box-shadow: none; background: #fff; border: 1px solid #d6d6d6; } + .section-shell,.page-hero-sm { padding: 0; } + .report-table th,.report-table td { font-size: 0.78rem; } } diff --git a/static/js/mileage_app.js b/static/js/mileage_app.js new file mode 100644 index 0000000..2dbf4c9 --- /dev/null +++ b/static/js/mileage_app.js @@ -0,0 +1,81 @@ +document.addEventListener('DOMContentLoaded', () => { + const form = document.querySelector('#trip-form'); + if (!form) return; + + const startInput = form.querySelector('#id_start_location'); + const endInput = form.querySelector('#id_end_location'); + const distanceInput = form.querySelector('#id_distance_miles'); + const startOdometerInput = form.querySelector('#id_start_odometer'); + const endOdometerInput = form.querySelector('#id_end_odometer'); + const useMapCheckbox = form.querySelector('#id_update_end_odometer_from_map'); + const statusBox = document.querySelector('#distance-status'); + const button = document.querySelector('#calculate-distance-btn'); + const endpoint = form.dataset.distanceEndpoint; + const csrfToken = form.querySelector('[name=csrfmiddlewaretoken]')?.value; + + const setStatus = (message, mode = '') => { + statusBox.textContent = message; + statusBox.classList.remove('is-success', 'is-error'); + if (mode) statusBox.classList.add(mode); + }; + + const maybeUpdateEndOdometer = () => { + if (!useMapCheckbox.checked) return; + const startOdometer = parseFloat(startOdometerInput.value || ''); + const distanceMiles = parseFloat(distanceInput.value || ''); + if (!Number.isNaN(startOdometer) && !Number.isNaN(distanceMiles)) { + endOdometerInput.value = (startOdometer + distanceMiles).toFixed(1); + } + }; + + const calculateDistance = async () => { + const startLocation = startInput.value.trim(); + const endLocation = endInput.value.trim(); + if (!startLocation || !endLocation) { + setStatus('Enter both locations to calculate route mileage.'); + return; + } + + button.disabled = true; + setStatus('Calculating driving miles from Google Maps…'); + + const body = new URLSearchParams({ + start_location: startLocation, + end_location: endLocation, + csrfmiddlewaretoken: csrfToken, + }); + + try { + const response = await fetch(endpoint, { + method: 'POST', + headers: { + 'X-Requested-With': 'XMLHttpRequest', + 'Content-Type': 'application/x-www-form-urlencoded;charset=UTF-8', + }, + body: body.toString(), + }); + const payload = await response.json(); + if (!response.ok || !payload.ok) { + setStatus(payload.message || 'Mileage could not be calculated. Please refine the addresses.', 'is-error'); + return; + } + distanceInput.value = Number(payload.miles).toFixed(1); + maybeUpdateEndOdometer(); + setStatus(`${payload.message} You can still override the miles before saving.`, 'is-success'); + } catch (error) { + setStatus('Mileage could not be calculated right now. Please try again or enter miles manually.', 'is-error'); + } finally { + button.disabled = false; + } + }; + + button.addEventListener('click', calculateDistance); + [startInput, endInput].forEach((input) => { + input.addEventListener('blur', () => { + if (startInput.value.trim() && endInput.value.trim()) calculateDistance(); + }); + }); + useMapCheckbox.addEventListener('change', maybeUpdateEndOdometer); + startOdometerInput.addEventListener('input', maybeUpdateEndOdometer); + distanceInput.addEventListener('input', maybeUpdateEndOdometer); +}); diff --git a/staticfiles/css/custom.css b/staticfiles/css/custom.css index 108056f..8633937 100644 --- a/staticfiles/css/custom.css +++ b/staticfiles/css/custom.css @@ -1,21 +1,130 @@ :root { - --bg-color-start: #6a11cb; - --bg-color-end: #2575fc; - --text-color: #ffffff; - --card-bg-color: rgba(255, 255, 255, 0.01); - --card-border-color: rgba(255, 255, 255, 0.1); + --app-bg: #f6f2ea; + --app-surface: rgba(255, 252, 248, 0.8); + --app-surface-strong: #fffaf3; + --app-border: rgba(17, 24, 39, 0.1); + --app-text: #18212b; + --app-muted: #5f6875; + --app-primary: #0e776f; + --app-primary-dark: #0a5d57; + --app-secondary: #152033; + --app-accent: #f07a56; + --app-highlight: #f2c66c; + --app-shadow: 0 28px 60px rgba(17, 24, 39, 0.12); + --app-radius-xl: 28px; + --app-radius-lg: 22px; + --app-radius-md: 16px; } +* { box-sizing: border-box; } +html { scroll-behavior: smooth; } body { margin: 0; - font-family: 'Inter', sans-serif; - background: linear-gradient(45deg, var(--bg-color-start), var(--bg-color-end)); - color: var(--text-color); - display: flex; - justify-content: center; - align-items: center; min-height: 100vh; - text-align: center; - overflow: hidden; - position: relative; + font-family: 'Inter', system-ui, -apple-system, BlinkMacSystemFont, sans-serif; + color: var(--app-text); + background: radial-gradient(circle at top left, rgba(240, 122, 86, 0.15), transparent 34%), radial-gradient(circle at 85% 15%, rgba(14, 119, 111, 0.18), transparent 24%), linear-gradient(180deg, #fcfaf6 0%, var(--app-bg) 100%); +} +h1,h2,h3,h4,h5,h6,.navbar-brand,.btn { font-family: 'Manrope', 'Inter', sans-serif; } +a { color: var(--app-primary); text-decoration: none; } +a:hover { color: var(--app-primary-dark); } +.site-shell { position: relative; overflow: hidden; } +.hero-grid { + position: fixed; inset: 0; + background-image: linear-gradient(rgba(17, 24, 39, 0.03) 1px, transparent 1px), linear-gradient(90deg, rgba(17, 24, 39, 0.03) 1px, transparent 1px); + background-size: 42px 42px; mask-image: linear-gradient(180deg, rgba(0, 0, 0, 0.35), transparent 75%); + pointer-events: none; z-index: -2; +} +.hero-orb { position: fixed; border-radius: 50%; pointer-events: none; z-index: -1; } +.orb-1 { width: 320px; height: 320px; top: -96px; right: -90px; background: radial-gradient(circle, rgba(240, 122, 86, 0.28), rgba(240, 122, 86, 0) 68%); } +.orb-2 { width: 400px; height: 400px; bottom: -160px; left: -120px; background: radial-gradient(circle, rgba(14, 119, 111, 0.18), rgba(14, 119, 111, 0) 70%); } +.site-header { padding: 1rem 0; backdrop-filter: blur(20px); background: rgba(250, 245, 238, 0.72); border-bottom: 1px solid rgba(17, 24, 39, 0.05); } +.app-navbar { padding: 0; } +.brand-lockup { display: inline-flex; align-items: center; gap: 0.85rem; } +.brand-badge { width: 42px; height: 42px; border-radius: 14px; display: inline-flex; align-items: center; justify-content: center; color: #fff; font-weight: 800; background: linear-gradient(135deg, var(--app-primary), #18a298); box-shadow: 0 16px 34px rgba(14, 119, 111, 0.22); } +.brand-name,.brand-tag { display: block; line-height: 1.05; } +.brand-name { font-size: 1rem; font-weight: 800; color: var(--app-secondary); } +.brand-tag { font-size: 0.75rem; color: var(--app-muted); letter-spacing: 0.04em; text-transform: uppercase; margin-top: 0.2rem; } +.nav-link { color: var(--app-muted); font-weight: 600; } +.nav-link:hover,.nav-link:focus { color: var(--app-secondary); } +.nav-toggle { border: 1px solid rgba(17, 24, 39, 0.08); border-radius: 14px; } +.hero-section,.page-hero-sm,.section-shell { position: relative; } +.hero-section { padding: 4.5rem 0 2rem; } +.page-hero-sm { padding: 3rem 0 1.25rem; } +.section-shell { padding: 1.5rem 0 3rem; } +.section-tight { padding-top: 0.5rem; } +.eyebrow,.section-kicker { display: inline-block; margin-bottom: 1rem; padding: 0.45rem 0.8rem; border-radius: 999px; color: var(--app-primary-dark); background: rgba(14, 119, 111, 0.12); font-size: 0.78rem; font-weight: 700; letter-spacing: 0.08em; text-transform: uppercase; } +.hero-title,.section-title { font-size: clamp(2.4rem, 4vw, 4.7rem); line-height: 0.98; letter-spacing: -0.04em; color: var(--app-secondary); margin-bottom: 1rem; } +.page-hero-sm .section-title,.h4.section-title { font-size: clamp(2rem, 3vw, 3rem); } +.hero-copy,.section-subtitle { max-width: 46rem; font-size: 1.05rem; color: var(--app-muted); line-height: 1.75; } +.hero-actions,.form-actions { margin-top: 2rem; } +.hero-note,.audit-caption,.table-muted,.field-help,.distance-status { color: var(--app-muted); font-size: 0.92rem; line-height: 1.55; } +.hero-note { margin-top: 1.15rem; } +.glass-panel,.metric-card,.feature-card,.action-card,.side-card,.trip-stream-item,.empty-state-card,.panel-solid { background: linear-gradient(180deg, rgba(255, 255, 255, 0.85), rgba(255, 250, 244, 0.78)); border: 1px solid var(--app-border); box-shadow: var(--app-shadow); border-radius: var(--app-radius-xl); } +.glass-panel { padding: clamp(1.25rem, 3vw, 2rem); backdrop-filter: blur(18px); } +.showcase-top,.section-heading-wrap,.trip-stream-footer,.distance-action-bar { display: flex; align-items: center; justify-content: space-between; gap: 1rem; flex-wrap: wrap; } +.status-pill,.trip-badge { display: inline-flex; align-items: center; gap: 0.4rem; padding: 0.4rem 0.8rem; border-radius: 999px; background: rgba(21, 32, 51, 0.08); color: var(--app-secondary); font-size: 0.8rem; font-weight: 700; } +.metric-stack,.side-stack,.action-list,.feature-grid,.trip-stream { display: grid; gap: 1rem; } +.metric-card,.feature-card,.side-card,.panel-solid { padding: 1.35rem; } +.metric-card h2 { font-size: clamp(2rem, 3.8vw, 3rem); line-height: 1; margin: 0.5rem 0; } +.metric-card p,.metric-card span,.feature-card p,.side-card p,.action-card span,.trip-stream-item p,.delete-box p { margin: 0; color: var(--app-muted); } +.metric-card p,.feature-card h3,.side-card h3,.action-card strong,.empty-state-card h3,.delete-box h3 { color: var(--app-secondary); } +.accent-card { background: linear-gradient(135deg, rgba(240, 122, 86, 0.16), rgba(242, 198, 108, 0.16)); } +.panel-solid { background: linear-gradient(180deg, #fffaf3, #fffdfa); } +.feature-grid { grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); margin-top: 1.5rem; } +.feature-icon { width: 42px; height: 42px; border-radius: 14px; display: inline-flex; align-items: center; justify-content: center; margin-bottom: 1rem; font-size: 0.82rem; font-weight: 800; color: #fff; background: linear-gradient(135deg, var(--app-secondary), #334155); } +.action-card { display: block; padding: 1.1rem 1.15rem; transition: transform 0.2s ease, border-color 0.2s ease; } +.action-card:hover { transform: translateY(-2px); border-color: rgba(14, 119, 111, 0.3); } +.trip-stream-item { padding: 1.2rem; } +.trip-stream-meta,.trip-stream-route,.trip-stream-footer { display: flex; gap: 0.85rem; align-items: center; justify-content: space-between; flex-wrap: wrap; } +.trip-stream-route { margin: 0.9rem 0; font-size: 1rem; } +.route-arrow { color: var(--app-accent); font-weight: 700; } +.empty-state-card { position: relative; text-align: center; padding: clamp(2rem, 5vw, 3rem); } +.empty-orb { width: 92px; height: 92px; margin: 0 auto 1rem; border-radius: 28px; background: linear-gradient(135deg, rgba(14, 119, 111, 0.18), rgba(240, 122, 86, 0.2)); } +.compact-empty { text-align: left; } +.form-label { font-weight: 700; color: var(--app-secondary); margin-bottom: 0.5rem; } +.form-control,.form-select { min-height: 3.2rem; border-radius: 16px; border: 1px solid rgba(17, 24, 39, 0.1); background: rgba(255, 255, 255, 0.94); padding: 0.85rem 1rem; color: var(--app-text); } +textarea.form-control { min-height: 8.5rem; } +.form-control:focus,.form-select:focus,.form-check-input:focus,.btn:focus { border-color: rgba(14, 119, 111, 0.52); box-shadow: 0 0 0 0.22rem rgba(14, 119, 111, 0.15); } +.field-error { color: #b42318; font-size: 0.9rem; margin-top: 0.35rem; } +.app-check { display: flex; align-items: center; gap: 0.7rem; } +.form-check-input { width: 1.15rem; height: 1.15rem; margin-top: 0; } +.form-check-input:checked { background-color: var(--app-primary); border-color: var(--app-primary); } +.distance-status { padding: 0.85rem 1rem; border-radius: 16px; background: rgba(21, 32, 51, 0.06); } +.distance-status.is-success { color: var(--app-primary-dark); background: rgba(14, 119, 111, 0.12); } +.distance-status.is-error { color: #b42318; background: rgba(212, 59, 48, 0.1); } +.timestamp-panel,.delete-box { padding: 1.2rem; border-radius: 22px; background: rgba(21, 32, 51, 0.04); border: 1px solid rgba(17, 24, 39, 0.06); } +.detail-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); gap: 1rem; } +.detail-grid article,.detail-copy p { line-height: 1.65; } +.detail-grid span { display: block; color: var(--app-muted); font-size: 0.88rem; margin-bottom: 0.35rem; } +.detail-grid strong,.detail-copy h2,.delete-box h3 { color: var(--app-secondary); } +.app-btn-primary,.app-btn-secondary { min-height: 3rem; padding: 0.85rem 1.25rem; border-radius: 999px; font-weight: 800; letter-spacing: -0.01em; } +.app-btn-primary { border: none; color: #fff; background: linear-gradient(135deg, var(--app-primary), #18a298); box-shadow: 0 16px 32px rgba(14, 119, 111, 0.22); } +.app-btn-primary:hover,.app-btn-primary:focus { color: #fff; background: linear-gradient(135deg, var(--app-primary-dark), #0e8c84); } +.app-btn-secondary { color: var(--app-secondary); border-color: rgba(21, 32, 51, 0.14); background: rgba(255, 255, 255, 0.72); } +.app-btn-secondary:hover,.app-btn-secondary:focus { color: var(--app-secondary); border-color: rgba(14, 119, 111, 0.32); background: rgba(255, 255, 255, 0.9); } +.app-alert { border-radius: 18px; border: 1px solid rgba(17, 24, 39, 0.06); } +.section-link { color: var(--app-secondary); font-weight: 700; } +.table-panel { overflow: hidden; } +.app-table { --bs-table-bg: transparent; --bs-table-border-color: rgba(17, 24, 39, 0.08); } +.app-table thead th { font-size: 0.78rem; text-transform: uppercase; letter-spacing: 0.08em; color: var(--app-muted); border-bottom-width: 1px; padding-top: 1.1rem; padding-bottom: 1.1rem; } +.app-table tbody td { padding-top: 1rem; padding-bottom: 1rem; color: var(--app-text); } +.no-print { display: initial; } +@media (max-width: 991.98px) { + .hero-section { padding-top: 3rem; } + .hero-title,.section-title { max-width: 16ch; } + .site-header { padding: 0.85rem 0; } +} +@media (max-width: 767.98px) { + .hero-section,.page-hero-sm { padding-top: 2rem; } + .glass-panel,.metric-card,.feature-card,.side-card,.trip-stream-item,.panel-solid { border-radius: 22px; } + .distance-action-bar,.section-heading-wrap,.trip-stream-footer,.trip-stream-route,.trip-stream-meta { align-items: flex-start; flex-direction: column; } + .table-responsive { border-radius: 22px; } +} +@media print { + body { background: #fff; } + .site-header,.hero-orb,.hero-grid,.no-print,.app-alert { display: none !important; } + .glass-panel,.panel-solid,.metric-card { box-shadow: none; background: #fff; border: 1px solid #d6d6d6; } + .section-shell,.page-hero-sm { padding: 0; } + .report-table th,.report-table td { font-size: 0.78rem; } } diff --git a/staticfiles/js/mileage_app.js b/staticfiles/js/mileage_app.js new file mode 100644 index 0000000..2dbf4c9 --- /dev/null +++ b/staticfiles/js/mileage_app.js @@ -0,0 +1,81 @@ +document.addEventListener('DOMContentLoaded', () => { + const form = document.querySelector('#trip-form'); + if (!form) return; + + const startInput = form.querySelector('#id_start_location'); + const endInput = form.querySelector('#id_end_location'); + const distanceInput = form.querySelector('#id_distance_miles'); + const startOdometerInput = form.querySelector('#id_start_odometer'); + const endOdometerInput = form.querySelector('#id_end_odometer'); + const useMapCheckbox = form.querySelector('#id_update_end_odometer_from_map'); + const statusBox = document.querySelector('#distance-status'); + const button = document.querySelector('#calculate-distance-btn'); + const endpoint = form.dataset.distanceEndpoint; + const csrfToken = form.querySelector('[name=csrfmiddlewaretoken]')?.value; + + const setStatus = (message, mode = '') => { + statusBox.textContent = message; + statusBox.classList.remove('is-success', 'is-error'); + if (mode) statusBox.classList.add(mode); + }; + + const maybeUpdateEndOdometer = () => { + if (!useMapCheckbox.checked) return; + const startOdometer = parseFloat(startOdometerInput.value || ''); + const distanceMiles = parseFloat(distanceInput.value || ''); + if (!Number.isNaN(startOdometer) && !Number.isNaN(distanceMiles)) { + endOdometerInput.value = (startOdometer + distanceMiles).toFixed(1); + } + }; + + const calculateDistance = async () => { + const startLocation = startInput.value.trim(); + const endLocation = endInput.value.trim(); + if (!startLocation || !endLocation) { + setStatus('Enter both locations to calculate route mileage.'); + return; + } + + button.disabled = true; + setStatus('Calculating driving miles from Google Maps…'); + + const body = new URLSearchParams({ + start_location: startLocation, + end_location: endLocation, + csrfmiddlewaretoken: csrfToken, + }); + + try { + const response = await fetch(endpoint, { + method: 'POST', + headers: { + 'X-Requested-With': 'XMLHttpRequest', + 'Content-Type': 'application/x-www-form-urlencoded;charset=UTF-8', + }, + body: body.toString(), + }); + const payload = await response.json(); + if (!response.ok || !payload.ok) { + setStatus(payload.message || 'Mileage could not be calculated. Please refine the addresses.', 'is-error'); + return; + } + distanceInput.value = Number(payload.miles).toFixed(1); + maybeUpdateEndOdometer(); + setStatus(`${payload.message} You can still override the miles before saving.`, 'is-success'); + } catch (error) { + setStatus('Mileage could not be calculated right now. Please try again or enter miles manually.', 'is-error'); + } finally { + button.disabled = false; + } + }; + + button.addEventListener('click', calculateDistance); + [startInput, endInput].forEach((input) => { + input.addEventListener('blur', () => { + if (startInput.value.trim() && endInput.value.trim()) calculateDistance(); + }); + }); + useMapCheckbox.addEventListener('change', maybeUpdateEndOdometer); + startOdometerInput.addEventListener('input', maybeUpdateEndOdometer); + distanceInput.addEventListener('input', maybeUpdateEndOdometer); +});