@mxpicture/tesla-superchargers-mate 0.1.62 → 0.1.64

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,183 @@
1
+ -- pic_refs: the precision `hours` needs now that pic_refs_v divides by it, and
2
+ -- two invariants stated as CHECK constraints.
3
+ --
4
+ -- (1) hours: 2 -> 6 dp. It was a display value, which is why 20260903171045
5
+ -- moved mean_draw_kw off it and onto the raw interval. It stops being one
6
+ -- here: pic_refs_v's gross_mean_draw_kw becomes
7
+ -- gross_dropped_kwh / gross_hours, so `hours` is a divisor one level up --
8
+ -- and rounding a divisor is what caused that defect in the first place. At
9
+ -- 2 dp the resulting error on gross_mean_draw_kw reaches 5.9455 kW and two
10
+ -- sub-18-second refs round to a zero divisor and vanish; at 6 dp the error
11
+ -- is 0.0020 kW and nothing vanishes. It also keeps a row self-consistent:
12
+ -- at 2 dp, 354 of 700 rows would show a gross_mean_draw_kw that does not
13
+ -- reconcile with the gross_hours printed beside it.
14
+ --
15
+ -- Nothing else moves. mean_draw_kw at 3 dp (1 W), cost_per_kwh at 4 (the
16
+ -- 0.08 EUR/kWh band the column exists to resolve) and the rest are terminal
17
+ -- display values that feed no computation, and each scale was chosen for
18
+ -- what its column measures.
19
+ --
20
+ -- (2) efficiency > 0. Four generation expressions carried NULLIF(efficiency, 0)
21
+ -- to keep a zero out of a divisor. All that could ever do was turn a broken
22
+ -- row into a silent NULL; stating the invariant once, checked on write, is
23
+ -- the same guarantee and a better failure. A car with efficiency = 0 now
24
+ -- fails the build loudly, which is consistent with what already happens for
25
+ -- a NULL efficiency (review Md-6). The NULLIF then comes out of those four
26
+ -- expressions, each keeping its round() scale exactly as it was.
27
+ --
28
+ -- (3) end_date >= start_date. An interval that runs backwards gives a negative
29
+ -- `hours` and a mean_draw_kw whose sign means the opposite of what it says.
30
+ -- Both CHECKs are validated against every existing row on ADD, and both
31
+ -- return zero rows on the dev history; re-run the two SELECTs in section 8
32
+ -- of the plan against production before applying.
33
+ --
34
+ -- The order below is forced, not stylistic. PG18 refuses SET EXPRESSION on a
35
+ -- VIRTUAL generated column while the table carries any CHECK constraint
36
+ -- (0A000), so every expression has to be rewritten before the CHECKs go on --
37
+ -- and a later migration that wants to change one of these columns has to drop
38
+ -- the two CHECKs, rewrite, and add them back. migrate:down runs the same
39
+ -- sequence in reverse for the same reason.
40
+ --
41
+ -- Every column touched is VIRTUAL, so SET EXPRESSION is a catalog edit and none
42
+ -- of this rewrites the table. The two CHECKs do scan it once.
43
+ --
44
+ -- docs/plans/2026-09-07_pic-refs-gross-ratios.md, sections 5 and 8.
45
+ -- migrate:up
46
+ ALTER TABLE pic_refs
47
+ ALTER COLUMN hours
48
+ SET
49
+ EXPRESSION AS (
50
+ round(
51
+ EXTRACT(
52
+ epoch
53
+ FROM
54
+ (end_date - start_date)
55
+ ) / 3600.0,
56
+ 6
57
+ )
58
+ );
59
+
60
+ ALTER TABLE pic_refs
61
+ ALTER COLUMN mean_draw_kw
62
+ SET
63
+ EXPRESSION AS (
64
+ round(
65
+ (start_range_km - end_range_km) * efficiency / NULLIF(
66
+ EXTRACT(
67
+ epoch
68
+ FROM
69
+ (end_date - start_date)
70
+ ) / 3600.0,
71
+ 0::NUMERIC
72
+ ),
73
+ 3
74
+ )
75
+ );
76
+
77
+ ALTER TABLE pic_refs
78
+ ALTER COLUMN dropped_kwh
79
+ SET
80
+ EXPRESSION AS ((start_range_km - end_range_km) * efficiency);
81
+
82
+ ALTER TABLE pic_refs
83
+ ALTER COLUMN kwh_per_real_1km
84
+ SET
85
+ EXPRESSION AS (
86
+ round(
87
+ (start_range_km - end_range_km) * efficiency / NULLIF(real_km, 0::NUMERIC),
88
+ 6
89
+ )
90
+ );
91
+
92
+ ALTER TABLE pic_refs
93
+ ALTER COLUMN cost_per_kwh
94
+ SET
95
+ EXPRESSION AS (
96
+ CASE
97
+ WHEN start_range_km < end_range_km THEN NULL
98
+ ELSE round(
99
+ cost / NULLIF(
100
+ (start_range_km - end_range_km) * efficiency,
101
+ 0::NUMERIC
102
+ ),
103
+ 4
104
+ )
105
+ END
106
+ );
107
+
108
+ ALTER TABLE pic_refs
109
+ ADD CONSTRAINT pic_refs_efficiency_positive CHECK (efficiency > 0);
110
+
111
+ ALTER TABLE pic_refs
112
+ ADD CONSTRAINT pic_refs_date_order CHECK (end_date >= start_date);
113
+
114
+ -- migrate:down
115
+ ALTER TABLE pic_refs
116
+ DROP CONSTRAINT IF EXISTS pic_refs_date_order;
117
+
118
+ ALTER TABLE pic_refs
119
+ DROP CONSTRAINT IF EXISTS pic_refs_efficiency_positive;
120
+
121
+ ALTER TABLE pic_refs
122
+ ALTER COLUMN cost_per_kwh
123
+ SET
124
+ EXPRESSION AS (
125
+ CASE
126
+ WHEN start_range_km < end_range_km THEN NULL
127
+ ELSE round(
128
+ cost / NULLIF(
129
+ (start_range_km - end_range_km) * NULLIF(efficiency, 0),
130
+ 0::NUMERIC
131
+ ),
132
+ 4
133
+ )
134
+ END
135
+ );
136
+
137
+ ALTER TABLE pic_refs
138
+ ALTER COLUMN kwh_per_real_1km
139
+ SET
140
+ EXPRESSION AS (
141
+ round(
142
+ (start_range_km - end_range_km) * NULLIF(efficiency, 0) / NULLIF(real_km, 0::NUMERIC),
143
+ 6
144
+ )
145
+ );
146
+
147
+ ALTER TABLE pic_refs
148
+ ALTER COLUMN dropped_kwh
149
+ SET
150
+ EXPRESSION AS (
151
+ (start_range_km - end_range_km) * NULLIF(efficiency, 0)
152
+ );
153
+
154
+ ALTER TABLE pic_refs
155
+ ALTER COLUMN mean_draw_kw
156
+ SET
157
+ EXPRESSION AS (
158
+ round(
159
+ (start_range_km - end_range_km) * NULLIF(efficiency, 0) / NULLIF(
160
+ EXTRACT(
161
+ epoch
162
+ FROM
163
+ (end_date - start_date)
164
+ ) / 3600.0,
165
+ 0::NUMERIC
166
+ ),
167
+ 3
168
+ )
169
+ );
170
+
171
+ ALTER TABLE pic_refs
172
+ ALTER COLUMN hours
173
+ SET
174
+ EXPRESSION AS (
175
+ round(
176
+ EXTRACT(
177
+ epoch
178
+ FROM
179
+ (end_date - start_date)
180
+ ) / 3600.0,
181
+ 2
182
+ )
183
+ );
@@ -0,0 +1,61 @@
1
+ -- pic_additional_costs: costs TeslaMate never sees -- service, repairs, tyres,
2
+ -- insurance, tax, subsidies -- entered by hand and attributed to a car and a day.
3
+ --
4
+ -- The first pic_* table whose rows are not derivable: everything else here is a
5
+ -- cache of a feed or of an algorithm and can be rebuilt, these can only be
6
+ -- retyped. No upsert path, no sync, no soft delete.
7
+ --
8
+ -- A recurring cost is ONE row: recurrence = 'monthly' with date as the first
9
+ -- occurrence, end_date as the last (NULL = still running) and cost as the amount
10
+ -- PER MONTH. The future Grafana view expands it with generate_series; nothing
11
+ -- materializes the occurrences.
12
+ -- migrate:up
13
+ CREATE TYPE pic_cost_category AS ENUM(
14
+ 'service',
15
+ 'repair',
16
+ 'tyres',
17
+ 'insurance',
18
+ 'tax',
19
+ 'funding', -- subsidies and quota credits; normally a NEGATIVE cost
20
+ 'other' -- escape hatch, so an unforeseen cost never needs a migration
21
+ );
22
+
23
+ CREATE TYPE pic_cost_recurrence AS ENUM('once', 'monthly');
24
+
25
+ CREATE TABLE IF NOT EXISTS pic_additional_costs (
26
+ id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
27
+ -- SMALLINT and no FK, like pic_refs.car_id: a REFERENCES cars (id) would put
28
+ -- constraint triggers on a TeslaMate table, which this project never writes to.
29
+ car_id SMALLINT NOT NULL,
30
+ category pic_cost_category NOT NULL,
31
+ -- A calendar day, not an instant; for a recurring row, the first occurrence.
32
+ -- Read and written as YYYY-MM-DD text by the store -- see the plan for why.
33
+ date DATE NOT NULL,
34
+ description TEXT NOT NULL,
35
+ -- EUR, like every stored cost here. Negative is allowed: a refund or a
36
+ -- 'funding' credit. On a recurring row this is the amount per occurrence.
37
+ cost NUMERIC(10, 2) NOT NULL,
38
+ recurrence pic_cost_recurrence NOT NULL DEFAULT 'once',
39
+ -- last occurrence of a monthly row; NULL = still running
40
+ end_date DATE,
41
+ created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
42
+ CONSTRAINT pic_additional_costs_description_ck CHECK (btrim(description) <> ''),
43
+ CONSTRAINT pic_additional_costs_end_date_ck CHECK (
44
+ end_date IS NULL
45
+ OR (
46
+ recurrence = 'monthly'
47
+ AND end_date >= date
48
+ )
49
+ )
50
+ );
51
+
52
+ -- The predicate a per-car cost-over-time panel will filter and order on.
53
+ -- NOT unique: several entries per car and day are explicitly allowed.
54
+ CREATE INDEX IF NOT EXISTS pic_additional_costs_car_date_idx ON pic_additional_costs (car_id, date);
55
+
56
+ -- migrate:down
57
+ DROP TABLE IF EXISTS pic_additional_costs;
58
+
59
+ DROP TYPE IF EXISTS pic_cost_recurrence;
60
+
61
+ DROP TYPE IF EXISTS pic_cost_category;
package/db/schema.sql CHANGED
@@ -11,6 +11,10 @@
11
11
 
12
12
  CREATE TYPE public.pic_address_type AS ENUM ('geofence', 'cluster', 'address', 'position');
13
13
 
14
+ CREATE TYPE public.pic_cost_category AS ENUM ('service', 'repair', 'tyres', 'insurance', 'tax', 'funding', 'other');
15
+
16
+ CREATE TYPE public.pic_cost_recurrence AS ENUM ('once', 'monthly');
17
+
14
18
  CREATE TYPE public.pic_ref_type AS ENUM ('drive', 'charge', 'idle');
15
19
 
16
20
  --
@@ -35,6 +39,39 @@ SET default_tablespace = '';
35
39
 
36
40
  SET default_table_access_method = heap;
37
41
 
42
+ --
43
+ -- Name: pic_additional_costs; Type: TABLE; Schema: public; Owner: -
44
+ --
45
+
46
+ CREATE TABLE public.pic_additional_costs (
47
+ id bigint NOT NULL,
48
+ car_id smallint NOT NULL,
49
+ category public.pic_cost_category NOT NULL,
50
+ date date NOT NULL,
51
+ description text NOT NULL,
52
+ cost numeric(10,2) NOT NULL,
53
+ recurrence public.pic_cost_recurrence DEFAULT 'once'::public.pic_cost_recurrence NOT NULL,
54
+ end_date date,
55
+ created_at timestamp with time zone DEFAULT now() NOT NULL,
56
+ CONSTRAINT pic_additional_costs_description_ck CHECK ((btrim(description) <> ''::text)),
57
+ CONSTRAINT pic_additional_costs_end_date_ck CHECK (((end_date IS NULL) OR ((recurrence = 'monthly'::public.pic_cost_recurrence) AND (end_date >= date))))
58
+ );
59
+
60
+
61
+ --
62
+ -- Name: pic_additional_costs_id_seq; Type: SEQUENCE; Schema: public; Owner: -
63
+ --
64
+
65
+ ALTER TABLE public.pic_additional_costs ALTER COLUMN id ADD GENERATED ALWAYS AS IDENTITY (
66
+ SEQUENCE NAME public.pic_additional_costs_id_seq
67
+ START WITH 1
68
+ INCREMENT BY 1
69
+ NO MINVALUE
70
+ NO MAXVALUE
71
+ CACHE 1
72
+ );
73
+
74
+
38
75
  --
39
76
  -- Name: pic_zip_country; Type: TABLE; Schema: public; Owner: -
40
77
  --
@@ -415,17 +452,19 @@ CREATE TABLE public.pic_refs (
415
452
  real_km numeric(8,4),
416
453
  used_cp_ids integer[],
417
454
  cost numeric(6,2),
418
- hours numeric GENERATED ALWAYS AS (round((EXTRACT(epoch FROM (end_date - start_date)) / 3600.0), 2)),
419
- mean_draw_kw numeric GENERATED ALWAYS AS (round((((start_range_km - end_range_km) * NULLIF(efficiency, (0)::numeric)) / NULLIF((EXTRACT(epoch FROM (end_date - start_date)) / 3600.0), (0)::numeric)), 3)),
455
+ hours numeric GENERATED ALWAYS AS (round((EXTRACT(epoch FROM (end_date - start_date)) / 3600.0), 6)),
456
+ mean_draw_kw numeric GENERATED ALWAYS AS (round((((start_range_km - end_range_km) * efficiency) / NULLIF((EXTRACT(epoch FROM (end_date - start_date)) / 3600.0), (0)::numeric)), 3)),
420
457
  dropped_km numeric GENERATED ALWAYS AS ((start_range_km - end_range_km)),
421
- dropped_kwh numeric GENERATED ALWAYS AS (((start_range_km - end_range_km) * NULLIF(efficiency, (0)::numeric))),
422
- kwh_per_real_1km numeric GENERATED ALWAYS AS (round((((start_range_km - end_range_km) * NULLIF(efficiency, (0)::numeric)) / NULLIF(real_km, (0)::numeric)), 6)),
458
+ dropped_kwh numeric GENERATED ALWAYS AS (((start_range_km - end_range_km) * efficiency)),
459
+ kwh_per_real_1km numeric GENERATED ALWAYS AS (round((((start_range_km - end_range_km) * efficiency) / NULLIF(real_km, (0)::numeric)), 6)),
423
460
  cost_per_real_100km numeric GENERATED ALWAYS AS (round(((cost * (100)::numeric) / NULLIF(real_km, (0)::numeric)), 2)),
424
461
  cost_per_kwh numeric GENERATED ALWAYS AS (
425
462
  CASE
426
463
  WHEN (start_range_km < end_range_km) THEN NULL::numeric
427
- ELSE round((cost / NULLIF(((start_range_km - end_range_km) * NULLIF(efficiency, (0)::numeric)), (0)::numeric)), 4)
428
- END)
464
+ ELSE round((cost / NULLIF(((start_range_km - end_range_km) * efficiency), (0)::numeric)), 4)
465
+ END),
466
+ CONSTRAINT pic_refs_date_order CHECK ((end_date >= start_date)),
467
+ CONSTRAINT pic_refs_efficiency_positive CHECK ((efficiency > (0)::numeric))
429
468
  );
430
469
 
431
470
 
@@ -534,6 +573,14 @@ CREATE UNLOGGED TABLE public.pic_zip_import (
534
573
  );
535
574
 
536
575
 
576
+ --
577
+ -- Name: pic_additional_costs pic_additional_costs_pkey; Type: CONSTRAINT; Schema: public; Owner: -
578
+ --
579
+
580
+ ALTER TABLE ONLY public.pic_additional_costs
581
+ ADD CONSTRAINT pic_additional_costs_pkey PRIMARY KEY (id);
582
+
583
+
537
584
  --
538
585
  -- Name: pic_chargers pic_chargers_location_guid_key; Type: CONSTRAINT; Schema: public; Owner: -
539
586
  --
@@ -678,6 +725,13 @@ ALTER TABLE ONLY public.pic_zip_country
678
725
  ADD CONSTRAINT pic_zip_country_pkey PRIMARY KEY (country_code);
679
726
 
680
727
 
728
+ --
729
+ -- Name: pic_additional_costs_car_date_idx; Type: INDEX; Schema: public; Owner: -
730
+ --
731
+
732
+ CREATE INDEX pic_additional_costs_car_date_idx ON public.pic_additional_costs USING btree (car_id, date);
733
+
734
+
681
735
  --
682
736
  -- Name: pic_charg_proc_link_uq_idx; Type: INDEX; Schema: public; Owner: -
683
737
  --
@@ -2669,17 +2723,17 @@ CREATE VIEW public.pic_refs_v AS
2669
2723
  ELSE r.cost
2670
2724
  END AS dropped_cost,
2671
2725
  CASE
2672
- WHEN (r.ref_type = 'charge'::public.pic_ref_type) THEN ((- r.cost) - COALESCE(i.cost, 0.0))
2673
- ELSE (r.cost + COALESCE(i.cost, 0.0))
2726
+ WHEN (r.ref_type = 'charge'::public.pic_ref_type) THEN (- s.gross_cost)
2727
+ ELSE s.gross_cost
2674
2728
  END AS gross_dropped_cost,
2675
- (r.cost + COALESCE(i.cost, 0.0)) AS gross_cost,
2676
- (r.hours + COALESCE(i.hours, 0.0)) AS gross_hours,
2677
- (r.mean_draw_kw + COALESCE(i.mean_draw_kw, 0.0)) AS gross_mean_draw_kw,
2678
- (r.dropped_km + COALESCE(i.dropped_km, 0.0)) AS gross_dropped_km,
2679
- (r.dropped_kwh + COALESCE(i.dropped_kwh, 0.0)) AS gross_dropped_kwh,
2680
- (r.kwh_per_real_1km + COALESCE(i.kwh_per_real_1km, 0.0)) AS gross_kwh_per_real_1km,
2681
- (r.cost_per_real_100km + COALESCE(i.cost_per_real_100km, 0.0)) AS gross_cost_per_real_100km,
2682
- (r.cost_per_kwh + COALESCE(i.cost_per_kwh, 0.0)) AS gross_cost_per_kwh,
2729
+ s.gross_cost,
2730
+ s.gross_hours,
2731
+ g.gross_mean_draw_kw,
2732
+ s.gross_dropped_km,
2733
+ s.gross_dropped_kwh,
2734
+ g.gross_kwh_per_real_1km,
2735
+ g.gross_cost_per_real_100km,
2736
+ g.gross_cost_per_kwh,
2683
2737
  sm.seed_address_id AS start_seed_address_id,
2684
2738
  em.seed_address_id AS end_seed_address_id,
2685
2739
  sm.cluster_id AS start_cluster_id,
@@ -2700,8 +2754,8 @@ CREATE VIEW public.pic_refs_v AS
2700
2754
  END AS end_address_type,
2701
2755
  COALESCE(sg.name, (sa.address)::character varying, (sa_fallback.address)::character varying) AS start_address,
2702
2756
  COALESCE(eg.name, (ea.address)::character varying, (ea_fallback.address)::character varying) AS end_address
2703
- FROM (((((((((public.pic_refs r
2704
- LEFT JOIN public.pic_refs i ON (((i.ref_type = 'idle'::public.pic_ref_type) AND (r.ref_type = 'drive'::public.pic_ref_type) AND (i.next_type = r.ref_type) AND (i.next_id = r.ref_id))))
2757
+ FROM (((((((((((public.pic_refs r
2758
+ LEFT JOIN public.pic_refs i ON (((i.ref_type = 'idle'::public.pic_ref_type) AND (r.ref_type = 'drive'::public.pic_ref_type) AND (i.car_id = r.car_id) AND (i.next_type = r.ref_type) AND (i.next_id = r.ref_id))))
2705
2759
  LEFT JOIN public.pic_cluster_map_v sm ON ((sm.address_id = r.start_address_id)))
2706
2760
  LEFT JOIN public.pic_cluster_map_v em ON ((em.address_id = r.end_address_id)))
2707
2761
  LEFT JOIN public.pic_addresses_v sa ON ((sa.id = sm.seed_address_id)))
@@ -2710,6 +2764,17 @@ CREATE VIEW public.pic_refs_v AS
2710
2764
  LEFT JOIN public.pic_addresses_v ea_fallback ON ((ea_fallback.id = r.end_address_id)))
2711
2765
  LEFT JOIN public.geofences sg ON ((sg.id = r.start_geofence_id)))
2712
2766
  LEFT JOIN public.geofences eg ON ((eg.id = r.end_geofence_id)))
2767
+ CROSS JOIN LATERAL ( SELECT (r.cost + COALESCE(i.cost, 0.0)) AS gross_cost,
2768
+ (r.hours + COALESCE(i.hours, 0.0)) AS gross_hours,
2769
+ (r.dropped_km + COALESCE(i.dropped_km, 0.0)) AS gross_dropped_km,
2770
+ (r.dropped_kwh + COALESCE(i.dropped_kwh, 0.0)) AS gross_dropped_kwh) s)
2771
+ CROSS JOIN LATERAL ( SELECT round((s.gross_dropped_kwh / NULLIF(s.gross_hours, (0)::numeric)), 3) AS gross_mean_draw_kw,
2772
+ round((s.gross_dropped_kwh / NULLIF(r.real_km, (0)::numeric)), 6) AS gross_kwh_per_real_1km,
2773
+ round(((s.gross_cost * (100)::numeric) / NULLIF(r.real_km, (0)::numeric)), 2) AS gross_cost_per_real_100km,
2774
+ CASE
2775
+ WHEN (s.gross_dropped_kwh <= (0)::numeric) THEN NULL::numeric
2776
+ ELSE round((s.gross_cost / s.gross_dropped_kwh), 4)
2777
+ END AS gross_cost_per_kwh) g)
2713
2778
  WHERE ((r.ref_id > 0) AND (COALESCE(r.prev_id, 0) >= 0));
2714
2779
 
2715
2780
 
@@ -0,0 +1,20 @@
1
+ import { type Cars, type CarsId, type CarsInitializer, type CarsMutator } from "@mxpicture/tesla-superchargers-generated/public";
2
+ import { IMateStore } from "./IMateStore.js";
3
+ import { type SqlTx } from "./db.js";
4
+ /** A car as a picker renders it; `name` is nullable in TeslaMate's own table. */
5
+ export interface CarOption {
6
+ id: CarsId;
7
+ name: string | null;
8
+ }
9
+ /**
10
+ * Read access to TeslaMate's `cars` table. Only {@link CarsStore.queryNames}
11
+ * today: the additional-costs form needs a car dropdown, and with no FK on
12
+ * `pic_additional_costs.car_id` a free-text number would book a cost against a
13
+ * car that may not exist. Read-only, like every TeslaMate table here.
14
+ */
15
+ export declare class CarsStore extends IMateStore<CarsId, Cars, CarsInitializer, CarsMutator> {
16
+ constructor();
17
+ queryNames(tx?: SqlTx): Promise<CarOption[]>;
18
+ }
19
+ export declare const carsStore: () => CarsStore;
20
+ //# sourceMappingURL=CarsStore.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"CarsStore.d.ts","sourceRoot":"","sources":["../../src/store/CarsStore.ts"],"names":[],"mappings":"AAAA,OAAO,EAGL,KAAK,IAAI,EACT,KAAK,MAAM,EACX,KAAK,eAAe,EACpB,KAAK,WAAW,EACjB,MAAM,iDAAiD,CAAC;AACzD,OAAO,EAAE,UAAU,EAAE,MAAM,iBAAiB,CAAC;AAC7C,OAAO,EAAO,KAAK,KAAK,EAAE,MAAM,SAAS,CAAC;AAE1C,iFAAiF;AACjF,MAAM,WAAW,SAAS;IACxB,EAAE,EAAE,MAAM,CAAC;IACX,IAAI,EAAE,MAAM,GAAG,IAAI,CAAC;CACrB;AAED;;;;;GAKG;AACH,qBAAa,SAAU,SAAQ,UAAU,CACvC,MAAM,EACN,IAAI,EACJ,eAAe,EACf,WAAW,CACZ;;IAKc,UAAU,CAAC,EAAE,GAAE,KAAa,GAAG,OAAO,CAAC,SAAS,EAAE,CAAC;CAGjE;AAGD,eAAO,MAAM,SAAS,iBAAqC,CAAC"}
@@ -0,0 +1,20 @@
1
+ import { carsInitializer, carsMutator, } from "@mxpicture/tesla-superchargers-generated/public";
2
+ import { IMateStore } from "./IMateStore.js";
3
+ import { sql } from "./db.js";
4
+ /**
5
+ * Read access to TeslaMate's `cars` table. Only {@link CarsStore.queryNames}
6
+ * today: the additional-costs form needs a car dropdown, and with no FK on
7
+ * `pic_additional_costs.car_id` a free-text number would book a cost against a
8
+ * car that may not exist. Read-only, like every TeslaMate table here.
9
+ */
10
+ export class CarsStore extends IMateStore {
11
+ constructor() {
12
+ super("cars", carsInitializer, carsMutator);
13
+ }
14
+ async queryNames(tx = sql()) {
15
+ return await tx `SELECT id, name FROM cars ORDER BY id`;
16
+ }
17
+ }
18
+ let _store = null;
19
+ export const carsStore = () => (_store ??= new CarsStore());
20
+ //# sourceMappingURL=CarsStore.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"CarsStore.js","sourceRoot":"","sources":["../../src/store/CarsStore.ts"],"names":[],"mappings":"AAAA,OAAO,EACL,eAAe,EACf,WAAW,GAKZ,MAAM,iDAAiD,CAAC;AACzD,OAAO,EAAE,UAAU,EAAE,MAAM,iBAAiB,CAAC;AAC7C,OAAO,EAAE,GAAG,EAAc,MAAM,SAAS,CAAC;AAQ1C;;;;;GAKG;AACH,MAAM,OAAO,SAAU,SAAQ,UAK9B;IACC;QACE,KAAK,CAAC,MAAM,EAAE,eAAe,EAAE,WAAW,CAAC,CAAC;IAC9C,CAAC;IAEM,KAAK,CAAC,UAAU,CAAC,KAAY,GAAG,EAAE;QACvC,OAAO,MAAM,EAAE,CAAa,uCAAuC,CAAC;IACtE,CAAC;CACF;AAED,IAAI,MAAM,GAAqB,IAAI,CAAC;AACpC,MAAM,CAAC,MAAM,SAAS,GAAG,GAAG,EAAE,CAAC,CAAC,MAAM,KAAK,IAAI,SAAS,EAAE,CAAC,CAAC"}
@@ -0,0 +1,60 @@
1
+ import type { PicAdditionalCostsId, PicCostCategory, PicCostRecurrence } from "@mxpicture/tesla-superchargers-generated/public";
2
+ import { type SqlTx } from "./db.js";
3
+ /**
4
+ * One stored entry. Both dates are `YYYY-MM-DD` and `cost` a NUMERIC string,
5
+ * exactly as they leave Postgres.
6
+ */
7
+ export interface AdditionalCost {
8
+ id: PicAdditionalCostsId;
9
+ car_id: number;
10
+ category: PicCostCategory;
11
+ date: string;
12
+ description: string;
13
+ cost: string;
14
+ recurrence: PicCostRecurrence;
15
+ end_date: string | null;
16
+ created_at: Date;
17
+ }
18
+ /** A full row as the UI edits it; `id` and `created_at` are the store's. */
19
+ export interface AdditionalCostInput {
20
+ car_id: number;
21
+ category: PicCostCategory;
22
+ /** YYYY-MM-DD */
23
+ date: string;
24
+ description: string;
25
+ cost: string;
26
+ recurrence: PicCostRecurrence;
27
+ /** YYYY-MM-DD, or null on a one-off row and an open-ended monthly one */
28
+ end_date: string | null;
29
+ }
30
+ /**
31
+ * `pic_additional_costs` — the costs TeslaMate never sees, typed in through
32
+ * the web UI. Full CRUD, because nothing else creates these rows: they are the
33
+ * only `pic_*` rows that cannot be rebuilt from a feed or an algorithm.
34
+ *
35
+ * Deliberately NOT an {@link IMateStore}, for the same reason
36
+ * {@link PicZipCodesStore} is not: the base class reads with `SELECT *` and
37
+ * writes through the generated zod schemas, and both put a JS `Date` on the
38
+ * path of a `DATE` column. postgres.js parses `DATE` (OID 1082) into a `Date`
39
+ * at *local* midnight, which `JSON.stringify` then emits as the previous day
40
+ * anywhere east of UTC; writing one back casts a timestamptz to `date` in the
41
+ * *session* zone, which is not the process's. So this store is the boundary
42
+ * and it speaks `YYYY-MM-DD` strings: every read projects `date::TEXT`, every
43
+ * write binds `${…}::DATE`, and no JS `Date` ever exists. Overriding all four
44
+ * inherited methods to do that would leave nothing inherited.
45
+ */
46
+ export declare class PicAdditionalCostsStore {
47
+ readonly table = "pic_additional_costs";
48
+ /** Newest day first; ties broken by insertion order, newest first. */
49
+ query(tx?: SqlTx): Promise<AdditionalCost[]>;
50
+ insert(input: AdditionalCostInput, tx?: SqlTx): Promise<AdditionalCost>;
51
+ /**
52
+ * Replace every editable column of one row — the UI edits a row as a whole,
53
+ * so there is no patch path. Null when no row has that id.
54
+ */
55
+ update(id: PicAdditionalCostsId, input: AdditionalCostInput, tx?: SqlTx): Promise<AdditionalCost | null>;
56
+ /** False when the row was already gone — deleting twice is not an error. */
57
+ remove(id: PicAdditionalCostsId, tx?: SqlTx): Promise<boolean>;
58
+ }
59
+ export declare const picAdditionalCostsStore: () => PicAdditionalCostsStore;
60
+ //# sourceMappingURL=PicAdditionalCostsStore.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"PicAdditionalCostsStore.d.ts","sourceRoot":"","sources":["../../src/store/PicAdditionalCostsStore.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EACV,oBAAoB,EACpB,eAAe,EACf,iBAAiB,EAClB,MAAM,iDAAiD,CAAC;AACzD,OAAO,EAAO,KAAK,KAAK,EAAE,MAAM,SAAS,CAAC;AAE1C;;;GAGG;AACH,MAAM,WAAW,cAAc;IAC7B,EAAE,EAAE,oBAAoB,CAAC;IACzB,MAAM,EAAE,MAAM,CAAC;IACf,QAAQ,EAAE,eAAe,CAAC;IAC1B,IAAI,EAAE,MAAM,CAAC;IACb,WAAW,EAAE,MAAM,CAAC;IACpB,IAAI,EAAE,MAAM,CAAC;IACb,UAAU,EAAE,iBAAiB,CAAC;IAC9B,QAAQ,EAAE,MAAM,GAAG,IAAI,CAAC;IACxB,UAAU,EAAE,IAAI,CAAC;CAClB;AAED,4EAA4E;AAC5E,MAAM,WAAW,mBAAmB;IAClC,MAAM,EAAE,MAAM,CAAC;IACf,QAAQ,EAAE,eAAe,CAAC;IAC1B,iBAAiB;IACjB,IAAI,EAAE,MAAM,CAAC;IACb,WAAW,EAAE,MAAM,CAAC;IACpB,IAAI,EAAE,MAAM,CAAC;IACb,UAAU,EAAE,iBAAiB,CAAC;IAC9B,yEAAyE;IACzE,QAAQ,EAAE,MAAM,GAAG,IAAI,CAAC;CACzB;AAED;;;;;;;;;;;;;;;GAeG;AACH,qBAAa,uBAAuB;IAClC,SAAgB,KAAK,0BAA0B;IAE/C,sEAAsE;IACzD,KAAK,CAAC,EAAE,GAAE,KAAa,GAAG,OAAO,CAAC,cAAc,EAAE,CAAC;IASnD,MAAM,CACjB,KAAK,EAAE,mBAAmB,EAC1B,EAAE,GAAE,KAAa,GAChB,OAAO,CAAC,cAAc,CAAC;IAe1B;;;OAGG;IACU,MAAM,CACjB,EAAE,EAAE,oBAAoB,EACxB,KAAK,EAAE,mBAAmB,EAC1B,EAAE,GAAE,KAAa,GAChB,OAAO,CAAC,cAAc,GAAG,IAAI,CAAC;IAiBjC,4EAA4E;IAC/D,MAAM,CACjB,EAAE,EAAE,oBAAoB,EACxB,EAAE,GAAE,KAAa,GAChB,OAAO,CAAC,OAAO,CAAC;CAIpB;AAGD,eAAO,MAAM,uBAAuB,+BACQ,CAAC"}
@@ -0,0 +1,71 @@
1
+ import { sql } from "./db.js";
2
+ /**
3
+ * `pic_additional_costs` — the costs TeslaMate never sees, typed in through
4
+ * the web UI. Full CRUD, because nothing else creates these rows: they are the
5
+ * only `pic_*` rows that cannot be rebuilt from a feed or an algorithm.
6
+ *
7
+ * Deliberately NOT an {@link IMateStore}, for the same reason
8
+ * {@link PicZipCodesStore} is not: the base class reads with `SELECT *` and
9
+ * writes through the generated zod schemas, and both put a JS `Date` on the
10
+ * path of a `DATE` column. postgres.js parses `DATE` (OID 1082) into a `Date`
11
+ * at *local* midnight, which `JSON.stringify` then emits as the previous day
12
+ * anywhere east of UTC; writing one back casts a timestamptz to `date` in the
13
+ * *session* zone, which is not the process's. So this store is the boundary
14
+ * and it speaks `YYYY-MM-DD` strings: every read projects `date::TEXT`, every
15
+ * write binds `${…}::DATE`, and no JS `Date` ever exists. Overriding all four
16
+ * inherited methods to do that would leave nothing inherited.
17
+ */
18
+ export class PicAdditionalCostsStore {
19
+ table = "pic_additional_costs";
20
+ /** Newest day first; ties broken by insertion order, newest first. */
21
+ async query(tx = sql()) {
22
+ return await tx `
23
+ SELECT
24
+ id, car_id, category, date::TEXT AS date, description, cost,
25
+ recurrence, end_date::TEXT AS end_date, created_at
26
+ FROM pic_additional_costs
27
+ ORDER BY date DESC, id DESC`;
28
+ }
29
+ async insert(input, tx = sql()) {
30
+ const [row] = await tx `
31
+ INSERT INTO pic_additional_costs (
32
+ car_id, category, date, description, cost, recurrence, end_date
33
+ ) VALUES (
34
+ ${input.car_id}, ${input.category}, ${input.date}::DATE,
35
+ ${input.description}, ${input.cost}, ${input.recurrence},
36
+ ${input.end_date}::DATE
37
+ )
38
+ RETURNING
39
+ id, car_id, category, date::TEXT AS date, description, cost,
40
+ recurrence, end_date::TEXT AS end_date, created_at`;
41
+ return row;
42
+ }
43
+ /**
44
+ * Replace every editable column of one row — the UI edits a row as a whole,
45
+ * so there is no patch path. Null when no row has that id.
46
+ */
47
+ async update(id, input, tx = sql()) {
48
+ const rows = await tx `
49
+ UPDATE pic_additional_costs SET
50
+ car_id = ${input.car_id},
51
+ category = ${input.category},
52
+ date = ${input.date}::DATE,
53
+ description = ${input.description},
54
+ cost = ${input.cost},
55
+ recurrence = ${input.recurrence},
56
+ end_date = ${input.end_date}::DATE
57
+ WHERE id = ${id}
58
+ RETURNING
59
+ id, car_id, category, date::TEXT AS date, description, cost,
60
+ recurrence, end_date::TEXT AS end_date, created_at`;
61
+ return rows[0] ?? null;
62
+ }
63
+ /** False when the row was already gone — deleting twice is not an error. */
64
+ async remove(id, tx = sql()) {
65
+ const result = await tx `DELETE FROM pic_additional_costs WHERE id = ${id}`;
66
+ return result.count > 0;
67
+ }
68
+ }
69
+ let _store = null;
70
+ export const picAdditionalCostsStore = () => (_store ??= new PicAdditionalCostsStore());
71
+ //# sourceMappingURL=PicAdditionalCostsStore.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"PicAdditionalCostsStore.js","sourceRoot":"","sources":["../../src/store/PicAdditionalCostsStore.ts"],"names":[],"mappings":"AAKA,OAAO,EAAE,GAAG,EAAc,MAAM,SAAS,CAAC;AA+B1C;;;;;;;;;;;;;;;GAeG;AACH,MAAM,OAAO,uBAAuB;IAClB,KAAK,GAAG,sBAAsB,CAAC;IAE/C,sEAAsE;IAC/D,KAAK,CAAC,KAAK,CAAC,KAAY,GAAG,EAAE;QAClC,OAAO,MAAM,EAAE,CAAkB;;;;;kCAKH,CAAC;IACjC,CAAC;IAEM,KAAK,CAAC,MAAM,CACjB,KAA0B,EAC1B,KAAY,GAAG,EAAE;QAEjB,MAAM,CAAC,GAAG,CAAC,GAAG,MAAM,EAAE,CAAkB;;;;UAIlC,KAAK,CAAC,MAAM,KAAK,KAAK,CAAC,QAAQ,KAAK,KAAK,CAAC,IAAI;UAC9C,KAAK,CAAC,WAAW,KAAK,KAAK,CAAC,IAAI,KAAK,KAAK,CAAC,UAAU;UACrD,KAAK,CAAC,QAAQ;;;;2DAImC,CAAC;QACxD,OAAO,GAAG,CAAC;IACb,CAAC;IAED;;;OAGG;IACI,KAAK,CAAC,MAAM,CACjB,EAAwB,EACxB,KAA0B,EAC1B,KAAY,GAAG,EAAE;QAEjB,MAAM,IAAI,GAAG,MAAM,EAAE,CAAkB;;mBAExB,KAAK,CAAC,MAAM;qBACV,KAAK,CAAC,QAAQ;iBAClB,KAAK,CAAC,IAAI;wBACH,KAAK,CAAC,WAAW;iBACxB,KAAK,CAAC,IAAI;uBACJ,KAAK,CAAC,UAAU;qBAClB,KAAK,CAAC,QAAQ;mBAChB,EAAE;;;2DAGsC,CAAC;QACxD,OAAO,IAAI,CAAC,CAAC,CAAC,IAAI,IAAI,CAAC;IACzB,CAAC;IAED,4EAA4E;IACrE,KAAK,CAAC,MAAM,CACjB,EAAwB,EACxB,KAAY,GAAG,EAAE;QAEjB,MAAM,MAAM,GAAG,MAAM,EAAE,CAAA,+CAA+C,EAAE,EAAE,CAAC;QAC3E,OAAO,MAAM,CAAC,KAAK,GAAG,CAAC,CAAC;IAC1B,CAAC;CACF;AAED,IAAI,MAAM,GAAmC,IAAI,CAAC;AAClD,MAAM,CAAC,MAAM,uBAAuB,GAAG,GAAG,EAAE,CAC1C,CAAC,MAAM,KAAK,IAAI,uBAAuB,EAAE,CAAC,CAAC"}
@@ -1,7 +1,9 @@
1
+ export * from "./CarsStore.js";
1
2
  export * from "./ChargingProcessesStore.js";
2
3
  export * from "./GeofencesStore.js";
3
4
  export * from "./IFailableStore.js";
4
5
  export * from "./IMateStore.js";
6
+ export * from "./PicAdditionalCostsStore.js";
5
7
  export * from "./PicCPLinksStore.js";
6
8
  export * from "./PicChargersStore.js";
7
9
  export * from "./PicInvoiceFilesStore.js";
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/store/index.ts"],"names":[],"mappings":"AACA,cAAc,6BAA6B,CAAC;AAC5C,cAAc,qBAAqB,CAAC;AACpC,cAAc,qBAAqB,CAAC;AACpC,cAAc,iBAAiB,CAAC;AAChC,cAAc,sBAAsB,CAAC;AACrC,cAAc,uBAAuB,CAAC;AACtC,cAAc,2BAA2B,CAAC;AAC1C,cAAc,uBAAuB,CAAC;AACtC,cAAc,yBAAyB,CAAC;AACxC,cAAc,oBAAoB,CAAC;AACnC,cAAc,uBAAuB,CAAC;AACtC,cAAc,eAAe,CAAC;AAC9B,cAAc,SAAS,CAAC;AACxB,cAAc,WAAW,CAAC;AAC1B,cAAc,aAAa,CAAC;AAC5B,cAAc,iBAAiB,CAAC"}
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/store/index.ts"],"names":[],"mappings":"AACA,cAAc,gBAAgB,CAAC;AAC/B,cAAc,6BAA6B,CAAC;AAC5C,cAAc,qBAAqB,CAAC;AACpC,cAAc,qBAAqB,CAAC;AACpC,cAAc,iBAAiB,CAAC;AAChC,cAAc,8BAA8B,CAAC;AAC7C,cAAc,sBAAsB,CAAC;AACrC,cAAc,uBAAuB,CAAC;AACtC,cAAc,2BAA2B,CAAC;AAC1C,cAAc,uBAAuB,CAAC;AACtC,cAAc,yBAAyB,CAAC;AACxC,cAAc,oBAAoB,CAAC;AACnC,cAAc,uBAAuB,CAAC;AACtC,cAAc,eAAe,CAAC;AAC9B,cAAc,SAAS,CAAC;AACxB,cAAc,WAAW,CAAC;AAC1B,cAAc,aAAa,CAAC;AAC5B,cAAc,iBAAiB,CAAC"}
@@ -1,8 +1,10 @@
1
1
  // This file is auto-generated by build-cli. Do not edit manually.
2
+ export * from "./CarsStore.js";
2
3
  export * from "./ChargingProcessesStore.js";
3
4
  export * from "./GeofencesStore.js";
4
5
  export * from "./IFailableStore.js";
5
6
  export * from "./IMateStore.js";
7
+ export * from "./PicAdditionalCostsStore.js";
6
8
  export * from "./PicCPLinksStore.js";
7
9
  export * from "./PicChargersStore.js";
8
10
  export * from "./PicInvoiceFilesStore.js";
@@ -1 +1 @@
1
- {"version":3,"file":"index.js","sourceRoot":"","sources":["../../src/store/index.ts"],"names":[],"mappings":"AAAA,kEAAkE;AAClE,cAAc,6BAA6B,CAAC;AAC5C,cAAc,qBAAqB,CAAC;AACpC,cAAc,qBAAqB,CAAC;AACpC,cAAc,iBAAiB,CAAC;AAChC,cAAc,sBAAsB,CAAC;AACrC,cAAc,uBAAuB,CAAC;AACtC,cAAc,2BAA2B,CAAC;AAC1C,cAAc,uBAAuB,CAAC;AACtC,cAAc,yBAAyB,CAAC;AACxC,cAAc,oBAAoB,CAAC;AACnC,cAAc,uBAAuB,CAAC;AACtC,cAAc,eAAe,CAAC;AAC9B,cAAc,SAAS,CAAC;AACxB,cAAc,WAAW,CAAC;AAC1B,cAAc,aAAa,CAAC;AAC5B,cAAc,iBAAiB,CAAC"}
1
+ {"version":3,"file":"index.js","sourceRoot":"","sources":["../../src/store/index.ts"],"names":[],"mappings":"AAAA,kEAAkE;AAClE,cAAc,gBAAgB,CAAC;AAC/B,cAAc,6BAA6B,CAAC;AAC5C,cAAc,qBAAqB,CAAC;AACpC,cAAc,qBAAqB,CAAC;AACpC,cAAc,iBAAiB,CAAC;AAChC,cAAc,8BAA8B,CAAC;AAC7C,cAAc,sBAAsB,CAAC;AACrC,cAAc,uBAAuB,CAAC;AACtC,cAAc,2BAA2B,CAAC;AAC1C,cAAc,uBAAuB,CAAC;AACtC,cAAc,yBAAyB,CAAC;AACxC,cAAc,oBAAoB,CAAC;AACnC,cAAc,uBAAuB,CAAC;AACtC,cAAc,eAAe,CAAC;AAC9B,cAAc,SAAS,CAAC;AACxB,cAAc,WAAW,CAAC;AAC1B,cAAc,aAAa,CAAC;AAC5B,cAAc,iBAAiB,CAAC"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mxpicture/tesla-superchargers-mate",
3
- "version": "0.1.62",
3
+ "version": "0.1.64",
4
4
  "description": "TeslaMate integration",
5
5
  "type": "module",
6
6
  "author": "MXPicture",
@@ -50,7 +50,7 @@
50
50
  "access": "public"
51
51
  },
52
52
  "dependencies": {
53
- "@mxpicture/tesla-superchargers-generated": "^0.1.62",
53
+ "@mxpicture/tesla-superchargers-generated": "^0.1.64",
54
54
  "dbmate": "2.35.1",
55
55
  "postgres": "^3.4.9",
56
56
  "short-uuid": "^6.0.3",
package/sql/reset.sql CHANGED
@@ -115,6 +115,13 @@ DROP PROCEDURE IF EXISTS pic_build_refs_car (SMALLINT);
115
115
 
116
116
  DROP PROCEDURE IF EXISTS pic_build_costs_car (SMALLINT);
117
117
 
118
+ DROP TABLE IF EXISTS pic_additional_costs;
119
+
120
+ -- After the table: both enums are only ever used by its columns.
121
+ DROP TYPE IF EXISTS pic_cost_recurrence;
122
+
123
+ DROP TYPE IF EXISTS pic_cost_category;
124
+
118
125
  DROP TABLE IF EXISTS pic_refs;
119
126
 
120
127
  DROP SEQUENCE IF EXISTS pic_refs_idle_id;
@@ -0,0 +1,55 @@
1
+ WITH
2
+ base AS (
3
+ SELECT
4
+ r.car_id,
5
+ r.ref_type,
6
+ sum(
7
+ EXTRACT(
8
+ EPOCH
9
+ FROM
10
+ r.end_date - r.start_date
11
+ )
12
+ ) AS duration_sec,
13
+ sum(r.real_km) AS real_km,
14
+ sum(r.start_range_km - r.end_range_km) AS dropped_km,
15
+ sum(r.cost) AS cost
16
+ FROM
17
+ pic_refs r
18
+ WHERE
19
+ r.car_id = 1
20
+ -- AND $__timeFilter(r.start_date)
21
+ GROUP BY
22
+ r.car_id,
23
+ r.ref_type
24
+ )
25
+ SELECT
26
+ r.*,
27
+ (
28
+ round(
29
+ (r.dropped_km * c.efficiency)::NUMERIC / NULLIF(r.duration_sec / 3600.0, (0)::NUMERIC),
30
+ 3
31
+ )
32
+ ) AS mean_draw_kw
33
+ -- kwh_per_real_1km
34
+ -- cost_per_real_100km
35
+ -- cost_per_kwh
36
+ -- floor(
37
+ -- extract(
38
+ -- epoch
39
+ -- FROM
40
+ -- r.start_date
41
+ -- )
42
+ -- ) * 1000 AS start_date_ts,
43
+ -- ceil(
44
+ -- extract(
45
+ -- epoch
46
+ -- FROM
47
+ -- r.end_date
48
+ -- )
49
+ -- ) * 1000 AS end_date_ts
50
+ FROM
51
+ base r
52
+ INNER JOIN cars c ON c.id = r.car_id;
53
+
54
+ -- ORDER BY
55
+ -- r.start_date DESC;
@@ -0,0 +1,181 @@
1
+ WITH
2
+ base AS (
3
+ SELECT
4
+ *
5
+ FROM
6
+ pic_refs_v
7
+ WHERE
8
+ car_id = 1
9
+ -- car_id = $car_id
10
+ -- AND $__timeFilter(start_date)
11
+ ),
12
+ latest_base AS (
13
+ SELECT
14
+ *
15
+ FROM
16
+ pic_refs_v b
17
+ WHERE
18
+ car_id = 1
19
+ -- car_id = $car_id
20
+ ORDER BY
21
+ start_date DESC,
22
+ end_date DESC
23
+ LIMIT
24
+ 1
25
+ ),
26
+ latest_selected AS (
27
+ SELECT
28
+ car_id,
29
+ ref_id,
30
+ ref_type
31
+ FROM
32
+ pic_refs_v b
33
+ WHERE
34
+ car_id = 1
35
+ -- car_id = $car_id
36
+ -- AND $__timeFilter(start_date)
37
+ AND ref_type = 'drive'
38
+ ORDER BY
39
+ start_date DESC,
40
+ end_date DESC
41
+ LIMIT
42
+ 1
43
+ ),
44
+ latest AS (
45
+ SELECT
46
+ b.car_id,
47
+ 'idle'::pic_ref_type AS ref_type,
48
+ (-100)::INTEGER AS ref_id,
49
+ end_date AS start_date,
50
+ (
51
+ SELECT
52
+ now()
53
+ ) AS end_date,
54
+ b.end_address AS start_address,
55
+ b.end_address,
56
+ NULL:NUMERIC AS real_km,
57
+ b.dropped_kwh,
58
+ b.gross_dropped_kwh,
59
+ b.dropped_cost,
60
+ b.gross_dropped_cost,
61
+ b.cost_per_real_100km,
62
+ b.gross_cost_per_real_100km,
63
+ b.kwh_per_real_1km,
64
+ b.gross_kwh_per_real_1km,
65
+ b.cost_per_kwh,
66
+ b.gross_cost_per_kwh
67
+ FROM
68
+ latest_base b
69
+ INNER JOIN latest_selected s ON s.car_id = b.car_id
70
+ AND s.ref_type = b.ref_type
71
+ AND s.ref_id = b.ref_id
72
+ LIMIT
73
+ 1
74
+ ),
75
+ res AS (
76
+ SELECT
77
+ rb.start_date,
78
+ rb.ref_type,
79
+ rb.end_date,
80
+ rb.start_address,
81
+ rb.end_address,
82
+ rb.real_km,
83
+ rb.dropped_kwh,
84
+ rb.gross_dropped_kwh,
85
+ rb.dropped_cost,
86
+ rb.gross_dropped_cost,
87
+ rb.cost_per_real_100km,
88
+ rb.gross_cost_per_real_100km,
89
+ rb.kwh_per_real_1km,
90
+ rb.gross_kwh_per_real_1km,
91
+ rb.cost_per_kwh,
92
+ rb.gross_cost_per_kwh,
93
+ rb.car_id,
94
+ rb.ref_id
95
+ FROM
96
+ base rb
97
+ UNION ALL
98
+ SELECT
99
+ rl.start_date,
100
+ rl.ref_type,
101
+ rl.end_date,
102
+ rl.start_address,
103
+ rl.end_address,
104
+ rl.real_km,
105
+ rl.dropped_kwh,
106
+ rl.gross_dropped_kwh,
107
+ rl.dropped_cost,
108
+ rl.gross_dropped_cost,
109
+ rl.cost_per_real_100km,
110
+ rl.gross_cost_per_real_100km,
111
+ rl.kwh_per_real_1km,
112
+ rl.gross_kwh_per_real_1km,
113
+ rl.cost_per_kwh,
114
+ rl.gross_cost_per_kwh,
115
+ rl.car_id,
116
+ rl.ref_id
117
+ FROM
118
+ latest rl
119
+ )
120
+ SELECT
121
+ r.start_date AS "Start",
122
+ CASE
123
+ WHEN r.ref_type = 'drive' THEN '🚗 Driving'
124
+ WHEN r.ref_type = 'idle' THEN '🅿️ Parking'
125
+ WHEN r.ref_type = 'charge' THEN '🔋 Charging'
126
+ ELSE NULL
127
+ END AS "Action",
128
+ EXTRACT(
129
+ EPOCH
130
+ FROM
131
+ r.end_date - r.start_date
132
+ ) AS "Duration",
133
+ r.start_address AS "From",
134
+ r.end_address AS "To",
135
+ r.real_km AS "Distance",
136
+ r.dropped_kwh AS "Energy (net)",
137
+ CASE
138
+ WHEN r.ref_type = 'drive' THEN r.gross_dropped_kwh
139
+ END AS "Energy (gross)",
140
+ r.dropped_cost AS "Cost (net)",
141
+ CASE
142
+ WHEN r.ref_type = 'drive' THEN r.gross_dropped_cost
143
+ END AS "Cost (gross)",
144
+ r.cost_per_real_100km AS "Cost / 100 km (net)",
145
+ CASE
146
+ WHEN r.ref_type = 'drive' THEN r.gross_cost_per_real_100km
147
+ END AS "Cost / 100 km (gross)",
148
+ r.kwh_per_real_1km AS "Energy / km (net)",
149
+ CASE
150
+ WHEN r.ref_type = 'drive' THEN r.gross_kwh_per_real_1km
151
+ END AS "Energy / km (gross)",
152
+ r.cost_per_kwh AS "Cost / kWh (net)",
153
+ CASE
154
+ WHEN r.ref_type = 'drive' THEN r.gross_cost_per_kwh
155
+ END AS "Cost / kWh (gross)",
156
+ floor(
157
+ extract(
158
+ epoch
159
+ FROM
160
+ r.start_date
161
+ )
162
+ ) * 1000 AS start_date_ts,
163
+ ceil(
164
+ extract(
165
+ epoch
166
+ FROM
167
+ r.end_date
168
+ )
169
+ ) * 1000 AS end_date_ts,
170
+ r.car_id,
171
+ r.ref_id AS drive_id,
172
+ -- h.climate_on_ratio,
173
+ -- CONCAT('F: ', round(h.front_defroster_ratio * 100.0, 0), ' %, R: ', round(h.rear_defroster_ratio * 100.0, 0), ' %') AS defroster_ratio,
174
+ h.battery_heater_ratio AS "Battery Heater"
175
+ FROM
176
+ res r
177
+ LEFT JOIN pic_refs_hvac_ratio_v h ON h.car_id = r.car_id
178
+ AND h.ref_type = r.ref_type
179
+ AND h.ref_id = r.ref_id
180
+ ORDER BY
181
+ r.start_date DESC;
package/sql/views.sql CHANGED
@@ -1049,18 +1049,25 @@ SELECT
1049
1049
  WHEN r.ref_type = 'charge'::pic_ref_type THEN - r.cost
1050
1050
  ELSE r.cost
1051
1051
  END AS dropped_cost,
1052
+ -- The gross_* block: this ref plus the idle that fed it. The extensive
1053
+ -- quantities -- cost, hours, dropped_km, dropped_kwh -- are summed in `s`
1054
+ -- and genuinely do add. The four ratios are recomputed over those sums in
1055
+ -- `g` and are never added: the sum of two rates is not the rate over the
1056
+ -- combined window. A 0.3 kW idle before a 25 kW drive reported 25.3 kW, and
1057
+ -- adding the two EUR/kWh figures was out by up to 0.5853 -- seven times the
1058
+ -- 0.08 EUR/kWh band that cost_per_kwh carries four decimals to resolve.
1052
1059
  CASE
1053
- WHEN r.ref_type = 'charge'::pic_ref_type THEN - r.cost - COALESCE(i.cost, 0.0)
1054
- ELSE r.cost + COALESCE(i.cost, 0.0)
1060
+ WHEN r.ref_type = 'charge'::pic_ref_type THEN - s.gross_cost
1061
+ ELSE s.gross_cost
1055
1062
  END AS gross_dropped_cost,
1056
- r.cost + COALESCE(i.cost, 0.0) AS gross_cost,
1057
- r.hours + COALESCE(i.hours, 0.0) AS gross_hours,
1058
- r.mean_draw_kw + COALESCE(i.mean_draw_kw, 0.0) AS gross_mean_draw_kw,
1059
- r.dropped_km + COALESCE(i.dropped_km, 0.0) AS gross_dropped_km,
1060
- r.dropped_kwh + COALESCE(i.dropped_kwh, 0.0) AS gross_dropped_kwh,
1061
- r.kwh_per_real_1km + COALESCE(i.kwh_per_real_1km, 0.0) AS gross_kwh_per_real_1km,
1062
- r.cost_per_real_100km + COALESCE(i.cost_per_real_100km, 0.0) AS gross_cost_per_real_100km,
1063
- r.cost_per_kwh + COALESCE(i.cost_per_kwh, 0.0) AS gross_cost_per_kwh,
1063
+ s.gross_cost,
1064
+ s.gross_hours,
1065
+ g.gross_mean_draw_kw,
1066
+ s.gross_dropped_km,
1067
+ s.gross_dropped_kwh,
1068
+ g.gross_kwh_per_real_1km,
1069
+ g.gross_cost_per_real_100km,
1070
+ g.gross_cost_per_kwh,
1064
1071
  sm.seed_address_id AS start_seed_address_id,
1065
1072
  em.seed_address_id AS end_seed_address_id,
1066
1073
  sm.cluster_id AS start_cluster_id,
@@ -1083,8 +1090,11 @@ SELECT
1083
1090
  COALESCE(eg.name, ea.address, ea_fallback.address) AS end_address
1084
1091
  FROM
1085
1092
  pic_refs r
1093
+ -- next_type/next_id identify the idle on their own, but the pack replay is
1094
+ -- per-car and the join says so.
1086
1095
  LEFT JOIN pic_refs i ON i.ref_type = 'idle'::pic_ref_type
1087
1096
  AND r.ref_type = 'drive'::pic_ref_type
1097
+ AND i.car_id = r.car_id
1088
1098
  AND i.next_type = r.ref_type
1089
1099
  AND i.next_id = r.ref_id
1090
1100
  LEFT JOIN pic_cluster_map_v sm ON sm.address_id = r.start_address_id
@@ -1095,6 +1105,47 @@ FROM
1095
1105
  LEFT JOIN pic_addresses_v ea_fallback ON ea_fallback.id = r.end_address_id
1096
1106
  LEFT JOIN geofences sg ON sg.id = r.start_geofence_id
1097
1107
  LEFT JOIN geofences eg ON eg.id = r.end_geofence_id
1108
+ -- Two chained LATERALs rather than generated columns on pic_refs: a
1109
+ -- generation expression cannot reference another generated column, and
1110
+ -- gross_mean_draw_kw needs both of `s`'s sums. Each returns exactly one row,
1111
+ -- so neither changes the cardinality above.
1112
+ CROSS JOIN LATERAL (
1113
+ SELECT
1114
+ r.cost + COALESCE(i.cost, 0.0) AS gross_cost,
1115
+ r.hours + COALESCE(i.hours, 0.0) AS gross_hours,
1116
+ r.dropped_km + COALESCE(i.dropped_km, 0.0) AS gross_dropped_km,
1117
+ r.dropped_kwh + COALESCE(i.dropped_kwh, 0.0) AS gross_dropped_kwh
1118
+ ) s
1119
+ -- The ratios over those sums, each rounded to the scale of its per-row twin
1120
+ -- so that gross_X and X read at the same precision. gross_hours is what
1121
+ -- makes `hours` load-bearing rather than a display value, and why
1122
+ -- 20260907181456 took it to 6 dp.
1123
+ --
1124
+ -- The per-km pair divides by r.real_km alone: an idle covers no distance, so
1125
+ -- the drive's kilometres are the whole of the combined window's. That makes
1126
+ -- both a per-trip fact with a legitimately heavy tail -- 24 of 320 drives
1127
+ -- exceed 1 kWh/km, because a one-kilometre drive after a three-day park
1128
+ -- genuinely did cost that much per km once standby is charged to it. The
1129
+ -- car's own consumption rate is the adjacent kwh_per_real_1km on the same
1130
+ -- row; the tail is not a defect and nothing here thresholds it away.
1131
+ --
1132
+ -- What the tail does break is averaging. Aggregate these as
1133
+ -- sum(numerator) / sum(denominator), never avg(ratio): over the same drives,
1134
+ -- sum(gross_dropped_kwh) / sum(real_km) is 0.2212 kWh/km where
1135
+ -- avg(gross_kwh_per_real_1km) reads 0.4740. Both sums are columns of this
1136
+ -- view.
1137
+ CROSS JOIN LATERAL (
1138
+ SELECT
1139
+ round(s.gross_dropped_kwh / NULLIF(s.gross_hours, 0), 3) AS gross_mean_draw_kw,
1140
+ round(s.gross_dropped_kwh / NULLIF(r.real_km, 0), 6) AS gross_kwh_per_real_1km,
1141
+ round(s.gross_cost * 100 / NULLIF(r.real_km, 0), 2) AS gross_cost_per_real_100km,
1142
+ -- The same guard as pic_refs.cost_per_kwh, for the same reason: a
1143
+ -- non-positive kWh denominator yields a EUR/kWh that does not exist.
1144
+ CASE
1145
+ WHEN s.gross_dropped_kwh <= 0 THEN NULL
1146
+ ELSE round(s.gross_cost / s.gross_dropped_kwh, 4)
1147
+ END AS gross_cost_per_kwh
1148
+ ) g
1098
1149
  WHERE
1099
1150
  -- The per-car seed (ref_id = -car_id) is a modelling artefact that gives the
1100
1151
  -- pack its opening balance; it is not a charge that happened. Anything