waka-api 1.0.1

WakaTime HTTP client library
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
//! `WakaTime` API response types.
//!
//! All types derive [`Debug`], [`Clone`], [`serde::Serialize`], and
//! [`serde::Deserialize`]. Unknown JSON fields are silently ignored via
//! `#[serde(deny_unknown_fields)]` is **not** used — this keeps the client
//! forward-compatible with new API fields.

// The WakaTime API returns several structs with more than 3 boolean fields
// (e.g. Goal has 5, Stats has 8). These fields mirror the upstream API
// exactly and cannot be meaningfully replaced with enums or bitflags.
#![allow(clippy::struct_excessive_bools)]

use serde::{Deserialize, Serialize};

// ─────────────────────────────────────────────────────────────────────────────
// User
// ─────────────────────────────────────────────────────────────────────────────

/// Top-level envelope returned by `GET /users/current`.
#[non_exhaustive]
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct UserResponse {
    /// The user object.
    pub data: User,
}

/// Geographic location returned on user profiles and leaderboards.
#[non_exhaustive]
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct City {
    /// Two-letter country code (e.g. `"US"`, `"UK"`).
    pub country_code: Option<String>,
    /// City name (e.g. `"San Francisco"`).
    pub name: Option<String>,
    /// State/province name (e.g. `"California"`).
    pub state: Option<String>,
    /// Human-readable `"City, State"` or country if state matches city.
    pub title: Option<String>,
}

/// A `WakaTime` user account.
#[non_exhaustive]
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct User {
    /// Unique user identifier (UUID).
    pub id: String,
    /// The user's login handle.
    pub username: String,
    /// Human-readable display name (full name or `@username`).
    pub display_name: String,
    /// Full legal name (may be `None` if not set).
    pub full_name: Option<String>,
    /// Optional user-defined bio.
    pub bio: Option<String>,
    /// Email address (only present when the authenticated user requests their
    /// own profile; requires `email` scope).
    pub email: Option<String>,
    /// Public-facing email (distinct from the account email).
    pub public_email: Option<String>,
    /// URL of the user's avatar image.
    pub photo: Option<String>,
    /// IANA timezone string (e.g. `"America/New_York"`).
    pub timezone: String,
    /// Personal website URL.
    pub website: Option<String>,
    /// Human-readable website URL (without protocol prefix).
    pub human_readable_website: Option<String>,
    /// Subscription plan (e.g. `"free"`, `"premium"`).
    pub plan: Option<String>,
    /// Whether the user has access to premium features.
    #[serde(default)]
    pub has_premium_features: bool,
    /// Whether the user's email address is publicly visible on leaderboards.
    #[serde(default)]
    pub is_email_public: bool,
    /// Whether the user's avatar is publicly visible on leaderboards.
    #[serde(default)]
    pub is_photo_public: bool,
    /// Whether the user's email address has been verified.
    #[serde(default)]
    pub is_email_confirmed: bool,
    /// Whether the user is open to work (shows "hireable" badge on profile).
    #[serde(default)]
    pub is_hireable: bool,
    /// Whether total coding time is visible on the public leaderboard.
    #[serde(default)]
    pub logged_time_public: bool,
    /// Whether language usage is visible on the public profile.
    #[serde(default)]
    pub languages_used_public: bool,
    /// Whether editor usage is visible on the public profile.
    #[serde(default)]
    pub editors_used_public: bool,
    /// Whether category usage is visible on the public profile.
    #[serde(default)]
    pub categories_used_public: bool,
    /// Whether operating system usage is visible on the public profile.
    #[serde(default)]
    pub os_used_public: bool,
    /// ISO 8601 timestamp of the most recently received heartbeat.
    pub last_heartbeat_at: Option<String>,
    /// User-agent string from the last plugin used.
    pub last_plugin: Option<String>,
    /// Editor name extracted from the last plugin user-agent.
    pub last_plugin_name: Option<String>,
    /// Name of the last project coded in.
    pub last_project: Option<String>,
    /// Name of the last branch coded in.
    pub last_branch: Option<String>,
    /// Geographic location associated with the account.
    pub city: Option<City>,
    /// `GitHub` username.
    pub github_username: Option<String>,
    /// Twitter/X handle.
    pub twitter_username: Option<String>,
    /// The user's `LinkedIn` username.
    pub linkedin_username: Option<String>,
    /// `wonderful.dev` username.
    pub wonderfuldev_username: Option<String>,
    // ── Fields below are not listed in the official API docs but are observed
    // ── in real responses — kept as optional for forward-compatibility.
    /// Geographic location string (legacy / undocumented).
    pub location: Option<String>,
    /// Absolute URL of the user's public profile (undocumented).
    pub profile_url: Option<String>,
    /// Whether this account is in write-only mode (undocumented).
    #[serde(default)]
    pub writes_only: bool,
    /// Heartbeat timeout in minutes (undocumented on this endpoint).
    pub timeout: Option<u32>,
    /// Whether the user prefers 24-hour time format (undocumented).
    pub time_format_24hr: Option<bool>,
    /// ISO 8601 timestamp when the account was created.
    pub created_at: String,
    /// ISO 8601 timestamp when the account was last modified.
    pub modified_at: Option<String>,
}

// ─────────────────────────────────────────────────────────────────────────────
// Summaries
// ─────────────────────────────────────────────────────────────────────────────

/// Top-level envelope returned by `GET /users/current/summaries`.
#[non_exhaustive]
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SummaryResponse {
    /// Per-day summary entries (one per calendar day in the requested range).
    pub data: Vec<SummaryData>,
    /// ISO 8601 end of the requested range.
    pub end: String,
    /// ISO 8601 start of the requested range.
    pub start: String,
    /// Cumulative total across all days in the requested range.
    #[serde(default)]
    pub cumulative_total: Option<CumulativeTotal>,
    /// Daily coding average for the requested range.
    #[serde(default)]
    pub daily_average: Option<DailyAverage>,
}

/// Cumulative coding total returned as part of [`SummaryResponse`].
#[non_exhaustive]
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct CumulativeTotal {
    /// Total seconds across all days.
    pub seconds: f64,
    /// Human-readable total (e.g. `"14 hrs 22 mins"`).
    pub text: String,
    /// Total in decimal format (e.g. `"14.38"`).
    #[serde(default)]
    pub decimal: String,
    /// Total in digital clock format (e.g. `"14:22"`).
    #[serde(default)]
    pub digital: String,
}

/// Daily average returned as part of [`SummaryResponse`].
#[non_exhaustive]
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct DailyAverage {
    /// Number of days in the range with no coding activity logged.
    #[serde(default)]
    pub holidays: u32,
    /// Total number of days in the range.
    #[serde(default)]
    pub days_including_holidays: u32,
    /// Number of days in the range excluding days with no activity.
    #[serde(default)]
    pub days_minus_holidays: u32,
    /// Average seconds per day, excluding the "Other" language.
    pub seconds: f64,
    /// Human-readable daily average, excluding the "Other" language.
    pub text: String,
    /// Average seconds per day, including all languages.
    pub seconds_including_other_language: f64,
    /// Human-readable daily average, including all languages.
    pub text_including_other_language: String,
}

/// Coding activity summary for a single calendar day.
#[non_exhaustive]
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct SummaryData {
    /// Time broken down by activity category (coding, browsing, etc.).
    #[serde(default)]
    pub categories: Vec<SummaryEntry>,
    /// Time broken down by detected dependency / library.
    #[serde(default)]
    pub dependencies: Vec<SummaryEntry>,
    /// Time broken down by editor.
    #[serde(default)]
    pub editors: Vec<SummaryEntry>,
    /// Daily grand total across all activity.
    pub grand_total: GrandTotal,
    /// Time broken down by programming language.
    #[serde(default)]
    pub languages: Vec<SummaryEntry>,
    /// Time broken down by machine / hostname.
    #[serde(default)]
    pub machines: Vec<MachineEntry>,
    /// Time broken down by operating system.
    #[serde(default)]
    pub operating_systems: Vec<SummaryEntry>,
    /// Time broken down by project.
    #[serde(default)]
    pub projects: Vec<SummaryEntry>,
    /// Time broken down by branch — only present when the `project` URL
    /// parameter is used in the request.
    #[serde(default)]
    pub branches: Vec<SummaryEntry>,
    /// Time broken down by entity (file/domain) — only present when the
    /// `project` URL parameter is used in the request.
    #[serde(default)]
    pub entities: Vec<SummaryEntry>,
    /// The date range this entry covers.
    pub range: SummaryRange,
}

/// A single time-breakdown entry (language, project, editor, OS, etc.).
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct SummaryEntry {
    /// Human-readable duration in `HH:MM` format.
    pub digital: String,
    /// Whole hours component.
    pub hours: u32,
    /// Whole minutes component (0–59).
    pub minutes: u32,
    /// Entity name (e.g. `"Python"`, `"my-project"`).
    pub name: String,
    /// Percentage of total time for the period (0.0–100.0).
    pub percent: f64,
    /// Whole seconds component (0–59).
    ///
    /// The `WakaTime` API occasionally omits this field (e.g. in `stats/all_time`
    /// project entries). Defaults to `0` when absent.
    #[serde(default)]
    pub seconds: u32,
    /// Full human-readable duration (e.g. `"3 hrs 30 mins"`).
    pub text: String,
    /// Total duration in seconds (fractional).
    pub total_seconds: f64,
    /// Lines added by AI coding assistants (present on project entries).
    #[serde(default)]
    pub ai_additions: u32,
    /// Lines removed by AI coding assistants (present on project entries).
    #[serde(default)]
    pub ai_deletions: u32,
    /// Lines added by the developer via keyboard input (present on project entries).
    #[serde(default)]
    pub human_additions: u32,
    /// Lines removed by the developer via keyboard input (present on project entries).
    #[serde(default)]
    pub human_deletions: u32,
}

/// A machine / hostname time-breakdown entry.
///
/// This is structurally similar to [`SummaryEntry`] but includes the
/// `machine_name_id` field returned by the API.
#[non_exhaustive]
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct MachineEntry {
    /// Human-readable duration in `HH:MM` format.
    pub digital: String,
    /// Whole hours component.
    pub hours: u32,
    /// Disambiguated machine identifier returned by the API.
    pub machine_name_id: String,
    /// Whole minutes component (0–59).
    pub minutes: u32,
    /// Human-readable machine name.
    pub name: String,
    /// Percentage of total time for the period (0.0–100.0).
    pub percent: f64,
    /// Whole seconds component (0–59).
    ///
    /// Defaults to `0` when absent — the API occasionally omits this field.
    #[serde(default)]
    pub seconds: u32,
    /// Full human-readable duration (e.g. `"1 hr 15 mins"`).
    pub text: String,
    /// Total duration in seconds (fractional).
    pub total_seconds: f64,
}

/// Grand total coding time for a single calendar day.
#[non_exhaustive]
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct GrandTotal {
    /// Human-readable duration in `HH:MM` format.
    pub digital: String,
    /// Whole hours component.
    pub hours: u32,
    /// Whole minutes component (0–59).
    pub minutes: u32,
    /// Whole seconds component (0–59).
    ///
    /// The `WakaTime` API omits this field from `grand_total` (it is present on
    /// per-language/project entries). Defaults to `0` when absent.
    #[serde(default)]
    pub seconds: u32,
    /// Full human-readable duration (e.g. `"6 hrs 42 mins"`).
    pub text: String,
    /// Total duration in seconds (fractional).
    pub total_seconds: f64,
    /// Lines added by AI coding assistants (e.g. GitHub Copilot) this day.
    #[serde(default)]
    pub ai_additions: u32,
    /// Lines removed by AI coding assistants this day.
    #[serde(default)]
    pub ai_deletions: u32,
    /// Lines added by the developer via keyboard input this day.
    #[serde(default)]
    pub human_additions: u32,
    /// Lines removed by the developer via keyboard input this day.
    #[serde(default)]
    pub human_deletions: u32,
}

/// Date range metadata attached to a [`SummaryData`] entry.
#[non_exhaustive]
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct SummaryRange {
    /// Calendar date string (e.g. `"2025-01-13"`). Present on single-day
    /// entries; may be absent on range queries.
    pub date: Option<String>,
    /// ISO 8601 end timestamp.
    pub end: String,
    /// ISO 8601 start timestamp.
    pub start: String,
    /// Human-readable description (e.g. `"today"`, `"yesterday"`).
    pub text: Option<String>,
    /// IANA timezone used when computing the range.
    pub timezone: Option<String>,
}

// ─────────────────────────────────────────────────────────────────────────────
// Projects list
// ─────────────────────────────────────────────────────────────────────────────

/// Top-level envelope returned by `GET /users/current/projects`.
#[non_exhaustive]
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ProjectsResponse {
    /// The list of projects.
    pub data: Vec<Project>,
}

/// A `WakaTime` project.
#[non_exhaustive]
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Project {
    /// Optional badge URL associated with the project.
    pub badge: Option<String>,
    /// Optional hex color code used in the `WakaTime` web UI.
    pub color: Option<String>,
    /// ISO 8601 timestamp when the project was first seen.
    pub created_at: String,
    /// Whether the project has a public shareable URL.
    pub has_public_url: bool,
    /// Human-readable last-heartbeat description (e.g. `"2 hours ago"`).
    pub human_readable_last_heartbeat_at: String,
    /// Human-readable first-heartbeat description.
    /// Only set for accounts created after 2024-02-05.
    pub human_readable_first_heartbeat_at: Option<String>,
    /// Unique project identifier (UUID).
    pub id: String,
    /// ISO 8601 timestamp of the last received heartbeat.
    pub last_heartbeat_at: String,
    /// ISO 8601 timestamp of the first received heartbeat.
    /// Only set for accounts created after 2024-02-05.
    pub first_heartbeat_at: Option<String>,
    /// Project name.
    pub name: String,
    /// Linked repository URL (if configured).
    pub repository: Option<String>,
    /// Public project URL (if `has_public_url` is `true`).
    pub url: Option<String>,
    /// URL-encoded version of the project name.
    pub urlencoded_name: String,
}

// ─────────────────────────────────────────────────────────────────────────────
// Stats
// ─────────────────────────────────────────────────────────────────────────────

/// Top-level envelope returned by `GET /users/current/stats/{range}`.
#[non_exhaustive]
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct StatsResponse {
    /// The aggregated stats object.
    pub data: Stats,
}

/// Aggregated coding stats for a predefined time range.
#[non_exhaustive]
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Stats {
    /// The day with the most coding activity.
    pub best_day: Option<BestDay>,
    /// Time broken down by activity category.
    pub categories: Vec<SummaryEntry>,
    /// ISO 8601 timestamp when this stats snapshot was created.
    pub created_at: String,
    /// Average daily coding time in seconds, excluding "Other" language.
    pub daily_average: f64,
    /// Average daily coding time in seconds, including all languages.
    pub daily_average_including_other_language: f64,
    /// Number of days in the range including weekends/holidays.
    pub days_including_holidays: u32,
    /// Number of working days in the range (excluding days with no activity).
    pub days_minus_holidays: u32,
    /// Time broken down by detected dependency / library.
    #[serde(default)]
    pub dependencies: Vec<SummaryEntry>,
    /// Time broken down by editor.
    pub editors: Vec<SummaryEntry>,
    /// ISO 8601 end of the stats range.
    pub end: String,
    /// Number of holiday / zero-activity days in the range.
    pub holidays: u32,
    /// Human-readable average daily coding time, excluding "Other" language.
    pub human_readable_daily_average: String,
    /// Human-readable average daily coding time, including all languages.
    #[serde(default)]
    pub human_readable_daily_average_including_other_language: String,
    /// Human-readable description of the range (e.g. `"last 7 days"`).
    pub human_readable_range: String,
    /// Human-readable total coding time, excluding "Other" language.
    pub human_readable_total: String,
    /// Human-readable total coding time, including all languages.
    #[serde(default)]
    pub human_readable_total_including_other_language: String,
    /// Lines added by AI coding assistants (e.g. GitHub Copilot).
    #[serde(default)]
    pub ai_additions: u32,
    /// Lines removed by AI coding assistants.
    #[serde(default)]
    pub ai_deletions: u32,
    /// Lines added by the developer via keyboard input.
    #[serde(default)]
    pub human_additions: u32,
    /// Lines removed by the developer via keyboard input.
    #[serde(default)]
    pub human_deletions: u32,
    /// Unique stats record identifier (not always present in the API response).
    pub id: Option<String>,
    /// Whether the stats snapshot is currently refreshing in the background.
    #[serde(default)]
    pub is_already_updating: bool,
    /// Whether this user's coding activity is publicly visible.
    #[serde(default)]
    pub is_coding_activity_visible: bool,
    /// Whether this user's language usage is publicly visible.
    #[serde(default)]
    pub is_language_usage_visible: bool,
    /// Whether this user's editor usage is publicly visible.
    #[serde(default)]
    pub is_editor_usage_visible: bool,
    /// Whether this user's category usage is publicly visible.
    #[serde(default)]
    pub is_category_usage_visible: bool,
    /// Whether this user's operating system usage is publicly visible.
    #[serde(default)]
    pub is_os_usage_visible: bool,
    /// Whether the stats calculation got stuck and will be retried.
    #[serde(default)]
    pub is_stuck: bool,
    /// Whether these stats include the current (partial) day.
    #[serde(default)]
    pub is_including_today: bool,
    /// Whether the stats snapshot is up to date.
    pub is_up_to_date: bool,
    /// Time broken down by programming language.
    pub languages: Vec<SummaryEntry>,
    /// Time broken down by machine.
    pub machines: Vec<MachineEntry>,
    /// ISO 8601 timestamp when this snapshot was last modified.
    pub modified_at: Option<String>,
    /// Time broken down by operating system.
    pub operating_systems: Vec<SummaryEntry>,
    /// How fully computed the stats are (0–100).
    pub percent_calculated: u32,
    /// Time broken down by project.
    pub projects: Vec<SummaryEntry>,
    /// Named range (e.g. `"last_7_days"`, `"last_30_days"`).
    pub range: String,
    /// ISO 8601 start of the stats range.
    pub start: String,
    /// Computation status (e.g. `"ok"`, `"pending_update"`).
    pub status: String,
    /// Heartbeat timeout in minutes used for this calculation.
    pub timeout: u32,
    /// IANA timezone used when computing the stats.
    pub timezone: String,
    /// Total coding time in seconds, excluding "Other" language.
    pub total_seconds: f64,
    /// Total coding time in seconds, including all languages.
    pub total_seconds_including_other_language: f64,
    /// Owner user identifier (UUID).
    pub user_id: String,
    /// Owner username.
    pub username: String,
    /// Whether the account is in write-only mode.
    pub writes_only: bool,
}

/// The single best (most productive) day in a stats range.
#[non_exhaustive]
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct BestDay {
    /// Calendar date (e.g. `"2025-01-13"`).
    pub date: String,
    /// Human-readable total for that day.
    pub text: String,
    /// Total coding seconds for that day.
    pub total_seconds: f64,
}

// ─────────────────────────────────────────────────────────────────────────────
// Goals
// ─────────────────────────────────────────────────────────────────────────────

/// Top-level envelope returned by `GET /users/current/goals`.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct GoalsResponse {
    /// The list of goals.
    pub data: Vec<Goal>,
    /// Total number of goals.
    pub total: u32,
    /// Total number of pages.
    pub total_pages: u32,
}

/// Minimal user info embedded in a [`Goal`] (owner / shared-with).
#[non_exhaustive]
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct GoalOwner {
    /// Human-readable display name.
    pub display_name: Option<String>,
    /// Email address.
    pub email: Option<String>,
    /// Full legal name.
    pub full_name: Option<String>,
    /// Unique user identifier (UUID).
    pub id: String,
    /// Avatar URL.
    pub photo: Option<String>,
    /// Public username.
    pub username: Option<String>,
}

/// A user this goal has been shared with.
#[non_exhaustive]
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct GoalSharedWith {
    /// Human-readable display name.
    pub display_name: Option<String>,
    /// Email address (only set if shared via email).
    pub email: Option<String>,
    /// Full legal name.
    pub full_name: Option<String>,
    /// Unique user identifier (UUID).
    pub id: Option<String>,
    /// Avatar URL.
    pub photo: Option<String>,
    /// Invitation acceptance status.
    pub status: Option<String>,
    /// User ID (only set if shared via user id).
    pub user_id: Option<String>,
    /// Username (only set if shared via username).
    pub username: Option<String>,
}

/// A subscriber to a goal's progress emails.
#[non_exhaustive]
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct GoalSubscriber {
    /// Subscriber's public email (if available).
    pub email: Option<String>,
    /// How often this subscriber receives emails.
    pub email_frequency: Option<String>,
    /// Subscriber's full name (if available).
    pub full_name: Option<String>,
    /// Unique user identifier (UUID).
    pub user_id: String,
    /// Subscriber's username (if defined).
    pub username: Option<String>,
}

/// A single coding goal.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Goal {
    /// `"fail"` when failure days outnumber success days, otherwise `"success"`.
    pub average_status: Option<String>,
    /// Per-period chart data (populated when fetching goal details).
    #[serde(default)]
    pub chart_data: Option<Vec<GoalChartEntry>>,
    /// ISO 8601 timestamp when the goal was created.
    pub created_at: String,
    /// Overall cumulative status across all delta periods
    /// (`"success"`, `"fail"`, or `"ignored"`).
    pub cumulative_status: Option<String>,
    /// Optional user-defined title that overrides the generated title.
    pub custom_title: Option<String>,
    /// Goal period (e.g. `"day"`, `"week"`).
    pub delta: String,
    /// Editors this goal is restricted to (empty = all editors).
    #[serde(default)]
    pub editors: Vec<String>,
    /// Unique goal identifier (UUID).
    pub id: String,
    /// Days of the week to ignore (e.g. `["saturday", "sunday"]`).
    #[serde(default)]
    pub ignore_days: Vec<String>,
    /// Whether days with zero activity are excluded from streak calculations.
    pub ignore_zero_days: bool,
    /// Target improvement percentage over baseline.
    pub improve_by_percent: Option<f64>,
    /// Whether the currently authenticated user owns this goal.
    #[serde(default)]
    pub is_current_user_owner: bool,
    /// Whether the goal is active.
    pub is_enabled: bool,
    /// Whether passing means staying *below* the target.
    pub is_inverse: bool,
    /// Whether the goal is temporarily snoozed.
    pub is_snoozed: bool,
    /// Whether achievements are tweeted automatically.
    pub is_tweeting: bool,
    /// Languages this goal is restricted to (empty = all languages).
    #[serde(default)]
    pub languages: Vec<String>,
    /// ISO 8601 timestamp when the goal was last modified (optional).
    pub modified_at: Option<String>,
    /// Owner of this goal.
    pub owner: Option<GoalOwner>,
    /// Projects this goal is restricted to (empty = all projects).
    #[serde(default)]
    pub projects: Vec<String>,
    /// Human-readable range description covering all delta periods.
    pub range_text: Option<String>,
    /// Status for the most recent period (`"success"`, `"fail"`, etc.).
    /// Present at the top level on list responses.
    pub range_status: Option<String>,
    /// Human-readable explanation of the most recent period's status.
    pub range_status_reason: Option<String>,
    /// Target coding seconds per period.
    pub seconds: f64,
    /// Users this goal has been shared with.
    #[serde(default)]
    pub shared_with: Vec<GoalSharedWith>,
    /// ISO 8601 timestamp when email notifications will be re-enabled.
    pub snooze_until: Option<String>,
    /// Overall goal status (e.g. `"success"`, `"fail"`, `"ignored"`, `"pending"`).
    pub status: String,
    /// Percent calculated (0–100) for goals that are pre-calculated in the background.
    #[serde(default)]
    pub status_percent_calculated: u32,
    /// Goal subscribers (users who receive progress emails).
    #[serde(default)]
    pub subscribers: Vec<GoalSubscriber>,
    /// Human-readable goal title.
    pub title: String,
    /// Goal type (e.g. `"coding"`).
    #[serde(rename = "type")]
    pub goal_type: String,
}

/// One data point in a goal's progress chart.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct GoalChartEntry {
    /// Actual coding seconds logged during this period.
    pub actual_seconds: f64,
    /// Human-readable actual coding time.
    pub actual_seconds_text: String,
    /// Target coding seconds for this period.
    pub goal_seconds: f64,
    /// Human-readable target coding time.
    pub goal_seconds_text: String,
    /// Date range this data point covers.
    pub range: GoalChartRange,
    /// Status for this period (e.g. `"success"`, `"fail"`).
    pub range_status: String,
    /// Human-readable explanation of the status.
    pub range_status_reason: String,
}

/// Date range metadata for a [`GoalChartEntry`].
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct GoalChartRange {
    /// Calendar date (e.g. `"2025-01-13"`). Only present when `delta` is `"day"`.
    pub date: Option<String>,
    /// ISO 8601 end timestamp.
    pub end: String,
    /// ISO 8601 start timestamp.
    pub start: String,
    /// Human-readable description (e.g. `"Mon, Jan 13"`).
    pub text: String,
    /// IANA timezone used when computing the range.
    pub timezone: String,
}

// ─────────────────────────────────────────────────────────────────────────────
// Leaderboard
// ─────────────────────────────────────────────────────────────────────────────

/// Top-level envelope returned by `GET /users/current/leaderboards`.
#[non_exhaustive]
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct LeaderboardResponse {
    /// The current authenticated user's entry, if they appear in this page.
    pub current_user: Option<LeaderboardEntry>,
    /// The leaderboard entries for this page.
    pub data: Vec<LeaderboardEntry>,
    /// Language filter applied (if any).
    pub language: Option<String>,
    /// ISO 8601 timestamp when the leaderboard was last updated.
    /// The `WakaTime` API occasionally omits this field.
    pub modified_at: Option<String>,
    /// Current page number (1-based).
    #[serde(default)]
    pub page: u32,
    /// Date range this leaderboard covers.
    pub range: Option<LeaderboardRange>,
    /// Heartbeat timeout in minutes used for ranking.
    #[serde(default)]
    pub timeout: u32,
    /// Total number of pages available.
    #[serde(default)]
    pub total_pages: u32,
    /// Whether the leaderboard is restricted to write-only accounts.
    #[serde(default)]
    pub writes_only: bool,
}

/// A single entry on the leaderboard.
///
/// Used for both regular entries in `data` and the `current_user` object.
/// `rank` is `None` when the authenticated user is not on the current page.
#[non_exhaustive]
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct LeaderboardEntry {
    /// The rank of this user (1 = top coder).
    /// `None` when the `current_user` is not present on this page.
    pub rank: Option<u32>,
    /// Aggregated coding totals for this user.
    pub running_total: RunningTotal,
    /// Public user information.
    pub user: LeaderboardUser,
}

/// Aggregated coding totals used on the leaderboard.
#[non_exhaustive]
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct RunningTotal {
    /// Average daily coding time in seconds.
    pub daily_average: f64,
    /// Human-readable average daily coding time.
    pub human_readable_daily_average: String,
    /// Human-readable total coding time for the period.
    pub human_readable_total: String,
    /// Top languages for this user (may be empty if privacy settings prevent
    /// disclosure).
    #[serde(default)]
    pub languages: Vec<SummaryEntry>,
    /// ISO 8601 timestamp when this total was last computed (not always present).
    pub modified_at: Option<String>,
    /// Total coding seconds for the leaderboard period.
    pub total_seconds: f64,
}

/// Public user information exposed on the leaderboard.
#[non_exhaustive]
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct LeaderboardUser {
    /// Human-readable display name.
    pub display_name: String,
    /// Public email (empty string if not shared).
    pub email: Option<String>,
    /// Full legal name.
    pub full_name: Option<String>,
    /// Human-readable website URL.
    pub human_readable_website: Option<String>,
    /// Unique user identifier (UUID).
    pub id: String,
    /// Whether the email address is publicly visible.
    pub is_email_public: bool,
    /// Whether the user is open to work.
    pub is_hireable: bool,
    /// Geographic location string.
    pub location: Option<String>,
    /// Avatar image URL.
    pub photo: Option<String>,
    /// Whether the avatar is publicly visible.
    pub photo_public: bool,
    /// Absolute URL of the user's public profile.
    pub profile_url: Option<String>,
    /// Public email address (distinct from account email).
    pub public_email: Option<String>,
    /// Login handle.
    pub username: Option<String>,
    /// Personal website URL.
    pub website: Option<String>,
}

/// Date range metadata for a [`LeaderboardResponse`].
#[non_exhaustive]
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct LeaderboardRange {
    /// End date string (e.g. `"2025-01-13"`).
    pub end_date: String,
    /// Short human-readable end date (e.g. `"Jan 13"`).
    pub end_text: String,
    /// Named range identifier (e.g. `"last_7_days"`).
    pub name: String,
    /// Start date string (e.g. `"2025-01-07"`).
    pub start_date: String,
    /// Short human-readable start date (e.g. `"Jan 7"`).
    pub start_text: String,
    /// Full human-readable range description (e.g. `"Last 7 Days"`).
    pub text: String,
}

#[cfg(test)]
mod tests {
    use super::*;

    /// Deserializing a minimal [`GrandTotal`] from JSON must succeed and
    /// `total_seconds` must be preserved exactly.
    #[test]
    fn grand_total_deserialize() {
        let json = r#"{
            "digital": "6:42",
            "hours": 6,
            "minutes": 42,
            "seconds": 0,
            "text": "6 hrs 42 mins",
            "total_seconds": 24120.0
        }"#;
        let gt: GrandTotal = serde_json::from_str(json).unwrap();
        assert_eq!(gt.hours, 6);
        assert_eq!(gt.minutes, 42);
        #[allow(clippy::float_cmp)]
        {
            assert_eq!(gt.total_seconds, 24120.0);
        }
        assert_eq!(gt.text, "6 hrs 42 mins");
    }

    /// Unknown fields in the JSON must be silently ignored.
    #[test]
    fn summary_entry_ignores_unknown_fields() {
        let json = r#"{
            "digital": "3:30",
            "hours": 3,
            "minutes": 30,
            "name": "Python",
            "percent": 52.3,
            "seconds": 0,
            "text": "3 hrs 30 mins",
            "total_seconds": 12600.0,
            "some_future_field": "ignored"
        }"#;
        let entry: SummaryEntry = serde_json::from_str(json).unwrap();
        assert_eq!(entry.name, "Python");
        assert_eq!(entry.hours, 3);
    }

    /// `SummaryData::dependencies` must default to an empty vec when absent.
    #[test]
    fn summary_data_default_dependencies() {
        let json = r#"{
            "categories": [],
            "editors": [],
            "grand_total": {
                "digital": "0:00",
                "hours": 0,
                "minutes": 0,
                "seconds": 0,
                "text": "0 secs",
                "total_seconds": 0.0
            },
            "languages": [],
            "operating_systems": [],
            "projects": [],
            "range": {
                "end": "2025-01-14T00:00:00Z",
                "start": "2025-01-13T00:00:00Z"
            }
        }"#;
        let data: SummaryData = serde_json::from_str(json).unwrap();
        assert!(data.dependencies.is_empty());
        assert!(data.machines.is_empty());
    }

    /// A serialized `User` must round-trip through JSON without loss.
    #[test]
    fn user_roundtrip() {
        let user = User {
            id: "abc-123".to_owned(),
            username: "johndoe".to_owned(),
            display_name: "John Doe".to_owned(),
            full_name: Some("John Doe".to_owned()),
            bio: None,
            email: None,
            public_email: None,
            photo: None,
            timezone: "UTC".to_owned(),
            website: None,
            human_readable_website: None,
            plan: Some("free".to_owned()),
            has_premium_features: false,
            is_email_public: false,
            is_photo_public: false,
            is_email_confirmed: true,
            is_hireable: false,
            logged_time_public: false,
            languages_used_public: false,
            editors_used_public: false,
            categories_used_public: false,
            os_used_public: false,
            last_heartbeat_at: None,
            last_plugin: None,
            last_plugin_name: None,
            last_project: None,
            last_branch: None,
            city: None,
            github_username: None,
            twitter_username: None,
            linkedin_username: None,
            wonderfuldev_username: None,
            location: None,
            profile_url: Some("https://wakatime.com/@johndoe".to_owned()),
            writes_only: false,
            timeout: Some(15),
            time_format_24hr: Some(false),
            created_at: "2024-01-01T00:00:00Z".to_owned(),
            modified_at: None,
        };
        let json = serde_json::to_string(&user).unwrap();
        let user2: User = serde_json::from_str(&json).unwrap();
        assert_eq!(user.id, user2.id);
        assert_eq!(user.username, user2.username);
        assert_eq!(user.timezone, user2.timezone);
    }
}