diff --git a/app/application.ml b/app/application.ml index bb2a059..941deb5 100644 --- a/app/application.ml +++ b/app/application.ml @@ -2477,6 +2477,7 @@ let timeline_page ~on_favorites_visible_range ~on_favorites_retry ~timeline_state + ~current_day ~loading ~graph_error ~sync_error @@ -2518,6 +2519,7 @@ let timeline_page |> V.Body.static | None -> Journal_timeline.view + ?current_day ?store:timeline_store ~on_region ~render_source @@ -6425,7 +6427,8 @@ let start let equal_calendar_projection left right = Option.equal (fun left right -> - Journal_calendar.equal_projection_fingerprint + Journal_calendar.local_day left = Journal_calendar.local_day right + && Journal_calendar.equal_projection_fingerprint (Journal_calendar.projection_fingerprint left) (Journal_calendar.projection_fingerprint right)) left.calendar @@ -6715,6 +6718,7 @@ let start | _ -> ())) ~on_favorites_retry:(bind_action dispatch Favorites_retry) ~timeline_state:state.timeline + ~current_day:(Option.map Journal_calendar.local_day state.calendar) ~loading:(not state.feed_loaded) ~graph_error:(Option.map graph_error_message state.graph_error) ~sync_error: @@ -7403,6 +7407,7 @@ module For_testing = struct ~on_favorites_visible_range:handler ~on_favorites_retry:handler ~timeline_state:(Journal_timeline_state.empty ~today:20260908) + ~current_day:None ~loading:false ~graph_error:None ~sync_error:None diff --git a/app/journal_calendar.ml b/app/journal_calendar.ml index ccbfb17..1444c78 100644 --- a/app/journal_calendar.ml +++ b/app/journal_calendar.ml @@ -210,3 +210,13 @@ let present_journal_day day = ; accessibility_label = date_text ^ ", " ^ full_weekdays.(index) }) ;; + +let relative_day_label ~today day = + if not (Journal_validation.is_journal_day today && Journal_validation.is_journal_day day) + then None + else + match days_from_civil today - days_from_civil day with + | 0 -> Some "today" + | 1 -> Some "yesterday" + | _ -> None +;; diff --git a/app/journal_calendar.mli b/app/journal_calendar.mli index 31e16c1..87a8c08 100644 --- a/app/journal_calendar.mli +++ b/app/journal_calendar.mli @@ -42,3 +42,5 @@ type date_presentation = } val present_journal_day : int -> (date_presentation, string) result + +val relative_day_label : today:int -> int -> string option diff --git a/app/journal_header.ml b/app/journal_header.ml index 0d4db1d..e9c7578 100644 --- a/app/journal_header.ml +++ b/app/journal_header.ml @@ -59,10 +59,10 @@ let feedback ~key ~top ~visible ~compact ~expanded body = feedback_with_visibility ~key ~top ~visible ~compact ~expanded body ;; -let date_header ~title = +let date_header ?relative_label ~title () = Ui.Native_widget.widget chrome - ~props:(props ~mode:"header" ~title ()) + ~props:(props ~mode:"header" ~title ?text:relative_label ()) ~on_event:(fun _ -> ()) ~children:[] () @@ -132,7 +132,7 @@ let detail ~capture_enabled ~on_capture ~copy_enabled ~on_copy ~copy_feedback bo Ui.Native_widget.widget chrome ~key:(Ui.Key.string "journal-detail-header") - ~props:(props ~mode:"detail" ~title:"Block" ()) + ~props:(props ~mode:"detail" ~title:"" ()) ~on_event:(fun _ -> ()) ~children:[ V.Body.Private.to_widget body; options ] () diff --git a/app/journal_header.mli b/app/journal_header.mli index 37914d1..4dcb8b1 100644 --- a/app/journal_header.mli +++ b/app/journal_header.mli @@ -65,7 +65,7 @@ val feedback -> Journal_view.View.Body.t -> Journal_view.View.Body.t -val date_header : title:string -> Journal_view.View.t +val date_header : ?relative_label:string -> title:string -> unit -> Journal_view.View.t val detail_text : id:string -> depth:int -> string -> Journal_view.View.t val detail_row : depth:int -> Journal_view.View.t -> Journal_view.View.t diff --git a/app/journal_timeline.ml b/app/journal_timeline.ml index e435852..a099623 100644 --- a/app/journal_timeline.ml +++ b/app/journal_timeline.ml @@ -179,6 +179,7 @@ end let loading_view () = V.loading ~centered:true ~message:"Loading journal" () let view + ?current_day ?store ?(on_region = fun _ -> ()) ~render_source @@ -198,6 +199,7 @@ let view = let slots = Timeline.fold_slots (fun acc slot -> slot :: acc) [] state |> List.rev in let today = Timeline.today state in + let current_day = Option.value current_day ~default:today in let metadata index slot : Journal_native_collection.row = let section, header, block_id = match slot with @@ -217,7 +219,9 @@ let view | Some value -> value.Journal_calendar.date_text | None -> fallback in - Journal_header.date_header ~title + Journal_header.date_header + ?relative_label:(Journal_calendar.relative_day_label ~today:current_day day) + ~title () |> V.with_test_id (Ui.Test_id.string ("journal-day-heading:" ^ string_of_int day)) in let render previous_slot slot = diff --git a/app/journal_timeline.mli b/app/journal_timeline.mli index 9f96e22..f898cad 100644 --- a/app/journal_timeline.mli +++ b/app/journal_timeline.mli @@ -15,7 +15,8 @@ end val loading_view : unit -> Ui.View.t val view - : ?store:Store.t + : ?current_day:int + -> ?store:Store.t -> ?on_region:(string -> unit) -> render_source:(string -> string) -> render_media: diff --git a/docs/agent-guide/implemented/feature/2026-10-14-timeline-relative-dates.md b/docs/agent-guide/implemented/feature/2026-10-14-timeline-relative-dates.md new file mode 100644 index 0000000..bb9f156 --- /dev/null +++ b/docs/agent-guide/implemented/feature/2026-10-14-timeline-relative-dates.md @@ -0,0 +1,45 @@ +# Timeline relative dates and empty Detail title + +## Problem + +Timeline dates need immediate today/yesterday context. Block Detail has a redundant visible title. + +## Decision + +Timeline appends lowercase `today` and `yesterday` after the corresponding dates using native `.subheadline.weight(.regular)` text with secondary foreground alongside the existing `.title2.weight(.semibold)` date, separated by an en space on the shared native Text baseline. Other dates remain unchanged. Detail supplies an empty navigation title while retaining the native bar, back button and options menu. + +## Authorization and ownership + +The delegated user request explicitly supplies the labels, local calendar semantics, unchanged date format, soft header, accessibility, dynamic sizing, update reuse and isolated PR scope. The follow-up explicitly authorizes removing `Block`, creating a formal PR, independent review and CI tracking without merging. No design questions remain. + +Application passes its sampled `Journal_calendar.local_day` as `Journal_timeline.view` current_day, so labels update immediately on the existing calendar refresh path, even while feed refresh is pending or has failed. Root presentation equality includes the local day as well as the time-zone projection fingerprint. `Journal_timeline_state.today` remains the existing feed/timestamp owner and the standalone component fallback. `Journal_calendar.relative_day_label` compares validated Gregorian civil day ordinals, without instant subtraction or extra timers. `Journal_header` carries only presentation props to the existing SwiftUI SectionDate. + +## Alternatives considered + +### Subtract 86400 seconds + +Rejected because local days can contain 23 or 25 hours and time zone changes alter local dates. + +### Add a SwiftUI date timer + +Rejected because the OCaml calendar already owns local date updates. + +## Consequences + +- Today/yesterday labels are correct across month, leap day, year, local midnight, time zone and DST boundaries; older/future dates have no label. +- Existing date text, native soft section behavior and header accessibility remain; suffix uses dynamic smaller text. +- Detail has no visible `Block` title; back and options remain available. +- No protected Dune/spec interfaces/locks or mldoc changes enter the PR. +- Native presentation tests cover civil boundaries and updates; all 86 application-view tests, existing calendar/Timeline tests and source-boundary checks pass. The Simulator app builds with the pinned lui source. An isolated iPhone 13 / iOS 26.1 synthetic host verifies actual date labels, next-day/year changes, maximum Dynamic Type, empty Detail title, native Back and menu selection. The host mounts the production components; it does not exercise a personal graph or real device. + +## Risks + +- A longer header has less horizontal room; verify compact and accessibility dynamic sizes on Simulator. + +## Regression ownership + +Calendar installation belongs to Application's private effect closure. Its public pure `Root_navigation` events do not expose calendar installation or delayed feed completion; the new regression therefore uses the existing `For_testing.app_with_service` boundary, injected sampler and real foreground packets. It blocks the actual fixture feed request and then fails it, rather than injecting an already incorrect projection. Day and zone cases demonstrate suffix changes before completion, retention after failure, and no added feed query. The independent review found this gap in head `6f8f026`; the initial tests failed with `today` where `yesterday` was required before the fix. + +## Relative-label typography + +The native subheadline style (15pt at default Dynamic Type) keeps today/yesterday readable beside the semibold 22pt date. Regular weight and the semantic system `Color.secondary` retain the auxiliary hierarchy without shrinking to the 13pt footnote size the user found too small or adding another hierarchical opacity reduction in the List header. An en space separates the two runs; the single Text preserves native baseline alignment, proportional shrinking, Dynamic Type and combined heading semantics. Actual same-device before/after screenshots and maximum accessibility sizing verify the refinement. diff --git a/swift/JournalChrome.swift b/swift/JournalChrome.swift index f6a2dfb..1e98055 100644 --- a/swift/JournalChrome.swift +++ b/swift/JournalChrome.swift @@ -170,12 +170,12 @@ struct JournalSheetDismissButton: SwiftUI.View { private struct SectionDate: SwiftUI.View { let title: String + let relativeLabel: String? @Environment(\.journalControlsSize) private var controlsSize var body: some SwiftUI.View { HStack(spacing: 0) { - Text(title) - .font(.title2.weight(.semibold)) + Text("\(Text(verbatim: title).font(.title2.weight(.semibold)))\(Text(verbatim: relativeLabel.map { "\u{2002}" + $0 } ?? "").font(.subheadline.weight(.regular)).foregroundStyle(Color.secondary))") .foregroundStyle(.primary) .monospacedDigit() .textCase(nil) @@ -225,7 +225,7 @@ struct JournalSheetDismissButton: SwiftUI.View { } case .header: if let properties, context.childIDs.isEmpty, let title = properties.title { - SectionDate(title: title) + SectionDate(title: title, relativeLabel: properties.text) } case .detail: if let title = properties?.title, context.childIDs.count == 2 { diff --git a/test/application_view_test.ml b/test/application_view_test.ml index f2eaacc..fbe5630 100644 --- a/test/application_view_test.ml +++ b/test/application_view_test.ml @@ -392,6 +392,7 @@ let run_favorites_native_visibility ?(check_navigation = false) ?(check_detail_row_action = false) ?(check_covered_update = false) + ?calendar_change ?(timeline_rows = 3) ?(check_generation = false) ?(check_chrome = false) @@ -420,6 +421,14 @@ let run_favorites_native_visibility let publish_error = Atomic.make None in let queried = Atomic.make [] in let feed_queried = Atomic.make false in + let calendar_feed_reads = Atomic.make 0 in + let calendar_feed_release = Atomic.make false in + let calendar_refresh_pending = Atomic.make false in + let calendar_seconds = ref (1_788_220_800. +. if calendar_change = Some `Zone then 82_800. else 0.) in + let calendar_offset = ref 0. in + let calendar_sampler = Option.map (fun _ -> Journal_calendar.Sampler.create + ~clock:(fun () -> !calendar_seconds) + ~localtime:(fun instant -> Unix.gmtime (instant +. !calendar_offset)) ()) calendar_change in let publish_block = Atomic.make false in let updated_block = Atomic.make false in let media_notices = Atomic.make [] in @@ -750,7 +759,11 @@ let run_favorites_native_visibility } | V2_list_journals _ -> Atomic.set feed_queried true; - V2_journals_outcome + let _ = Atomic.fetch_and_add calendar_feed_reads 1 in + if Atomic.get calendar_refresh_pending then ( + while not (Atomic.get calendar_feed_release) do Unix.sleepf 0.001 done; + V2_failed {code="FixtureCalendarRefresh"; message="Calendar refresh fixture failed"}) + else V2_journals_outcome { items = [ { page; journal_day = 20260901; revision = "p" } ] ; next_cursor = None } @@ -1112,6 +1125,7 @@ let run_favorites_native_visibility let regions = Hashtbl.create 8 in let hooks = Application.For_testing.app_with_service + ?calendar_sampler ~on_client:(fun value -> client := Some value) ~on_platform_request:(fun request -> if Bytes.length request >= 32 && Bytes.get_uint16_le request 6 = 28 @@ -1263,6 +1277,7 @@ let run_favorites_native_visibility Fun.protect ~finally:(fun () -> Atomic.set child_release true; + Atomic.set calendar_feed_release true; Atomic.set detail_wait_release true; Atomic.set import_release true; Atomic.set acquire_release true; @@ -1295,6 +1310,48 @@ let run_favorites_native_visibility wait "Journals mounted" (fun () -> Option.is_some (find "accessibility-label" "Favorites") && Option.is_some (find "text" "Timeline fixture 2")); + if Option.is_some calendar_change then ( + let label () = Hashtbl.fold (fun _ values found -> + match List.assoc_opt "payload" values with + | Some (`String payload) -> + let props = Yojson.Safe.from_string payload in + let open Yojson.Safe.Util in + if member "mode" props = `String "header" && member "title" props = `String "2026.09.01" + then (match member "text" props with `String text -> Some text | _ -> None) else found + | _ -> found) props None in + Alcotest.(check (option string)) "initial sampled date is today" (Some "today") (label ()); + let lifecycle kind = + let packet = Bytes.make 48 '\000' in + Bytes.blit_string "LJP2" 0 packet 0 4; + Bytes.set_uint16_le packet 4 2; + Bytes.set_uint16_le packet 6 15; + Bytes.set_int32_le packet 24 16l; + Bytes.blit_string "LJP1" 0 packet 32 4; + Bytes.set_uint16_le packet 36 1; + Bytes.set_uint16_le packet 38 kind; + Bytes.set_int64_le packet 40 1L; + hooks.platform_event (Bytes.to_string packet); + hooks.pump () |> consume in + lifecycle 1; + (match calendar_change with + | Some `Zone -> calendar_offset := 7_200. + | Some `Day -> calendar_seconds := !calendar_seconds +. 86_400. + | None -> ()); + let previous_reads = Atomic.get calendar_feed_reads in + Atomic.set calendar_refresh_pending true; + lifecycle 2; + wait "calendar refresh starts with retained feed" (fun () -> Atomic.get calendar_feed_reads > previous_reads); + Alcotest.(check bool) "feed is still pending" false (Atomic.get calendar_feed_release); + Alcotest.(check (option string)) "sampled date updates before feed completion" + (Some "yesterday") (label ()); + Atomic.set calendar_feed_release true; + wait "failed refresh exposes current error" (fun () -> Option.is_some (find "accessibility-label" "Error info")); + Alcotest.(check (option string)) "failed refresh retains the correct date suffix" + (Some "yesterday") (label ()); + Alcotest.(check bool) "failed refresh keeps the existing Timeline visible" true + (Option.is_some (find "text" "Timeline fixture 2")); + Alcotest.(check int) "labels add no feed queries" (previous_reads + 1) + (Atomic.get calendar_feed_reads)); if check_detail_wait then ( Atomic.set detail_fixture true; let root_list = Option.get (find "_extension" "journal-list") in @@ -4064,6 +4121,116 @@ let test_timeline_media_bounded_body_and_owner_replacement () = (journals <> find_text (Journal_model.source block))) ;; +let with_chrome_payloads initial build run = + let payloads = Hashtbl.create 16 in + let track = track_wire_teardown (Hashtbl.remove payloads) in + let backend : Lui_protocol.backend = + { backend_profile = Lui_protocol.profile IOS SwiftUIHost + ; apply_batch = (fun batch -> + List.iter (fun op -> + track op; + match op with + | Lui_protocol.SetExtensionProp (id, "payload", StringValue value) -> + Hashtbl.replace payloads id (Yojson.Safe.from_string value) + | _ -> ()) batch.Lui_protocol.ops; + true) + } + in + let app = Lui_app.create_with_extensions backend Journal_lui_native.registry + initial (fun _ next -> next) + (fun _ signal _ -> Lui_elements.dyn ~equal:( = ) + (fun state -> Journal_view.mount (build state)) signal) + in + Fun.protect ~finally:(fun () -> ignore (Lui_app.dispose app)) (fun () -> + Alcotest.(check bool) "chrome mounts" true (Lui_app.start app); + ignore (Lui_app.flush app); + run app payloads) +;; + +let test_timeline_relative_date_headers () = + let noop = Journal_view.Event.Handler.create (fun _ -> ()) in + let check ~today ~yesterday ~other = + let module T = Journal_timeline_state in + let days = [today; yesterday; other] in + let block day = + Journal_model.create ~id:(Printf.sprintf "%08d-0000-4000-8000-000000000001" day) + ~page_id:(Printf.sprintf "%08d-0000-4000-8000-000000000002" day) + ~journal_day:day ~parent_id:None ~sibling_order:"a" ~source:"Date fixture" + ~task_state:No_status ~child_count:0 ~revision:"r1" + ~last_mutation_id:"00000001-0000-4000-8000-000000000003" + ~creation_time:(Journal_time.create ~instant_unix_ms:0L ~local_day:day + ~local_minute_of_day:0 |> Result.get_ok) |> Result.get_ok + in + let feed : Journal_graph_projection.feed = + { days = List.map (fun day -> + { Journal_graph_projection.page = + { id = string_of_int day; day; title = "fallback" } + ; entries = [{block=block day; child_summaries=[]}]; has_more_entries = false; continuation = None }) days + ; slot_count = 5; has_more_days = false } + in + let state = T.empty ~today + |> fun state -> T.begin_request state ~generation:1L (Feed {before_day=None}) + |> fun state -> T.apply_feed state ~generation:1L feed + in + let build state = Journal_timeline.view ~state + ~render_source:Fun.id ~render_media:(fun ~title:_ ~root:_ ~image_children:_ child -> child) + ~day_presentation:(fun day -> Journal_calendar.present_journal_day day |> Result.to_option) + ~on_visible_range:noop ~on_scroll_completed:noop ~on_retry_day:noop + ~on_open_block:noop ~delete_enabled:false ~actions_enabled:false + ~on_status:noop ~on_delete:noop ~on_copy:noop () + |> Journal_view.View.Body.Private.to_widget + in + with_chrome_payloads state build (fun app payloads -> + let header day = + let title = (Journal_calendar.present_journal_day day |> Result.get_ok).date_text in + Hashtbl.fold (fun _ props found -> + let open Yojson.Safe.Util in + if member "mode" props = `String "header" && member "title" props = `String title + then Some props else found) payloads None |> Option.get + in + let assert_label day expected = + let actual = Yojson.Safe.Util.member "text" (header day) in + Alcotest.(check (option string)) (Printf.sprintf "relative label %d today %d" day today) + expected (match actual with `String text -> Some text | _ -> None) + in + assert_label today (Some "today"); + assert_label yesterday (Some "yesterday"); + assert_label other None; + ignore (Lui_app.send app (T.set_today state ~today:yesterday)); + ignore (Lui_app.flush app); + assert_label yesterday (Some "today"); + assert_label today None) + in + List.iter (fun (today, yesterday, other) -> check ~today ~yesterday ~other) + [20261014,20261013,20261012; 20261001,20260930,20261002; + 20270101,20261231,20261230; 20240301,20240229,20240228; + 20260301,20260228,20260302; 20260309,20260308,20260307; + 20261102,20261101,20261031]; + let sample seconds offset = Journal_calendar.Sampler.create + ~clock:(fun () -> seconds) ~localtime:(fun instant -> Unix.gmtime (instant +. offset)) () + |> Journal_calendar.Sampler.sample |> Result.get_ok |> Journal_calendar.local_day in + (* The same instant is yesterday in UTC-8 and today in UTC+8. *) + check ~today:(sample 1_788_537_600. (-28_800.)) ~yesterday:20260903 ~other:20260905; + check ~today:(sample 1_788_537_600. 28_800.) ~yesterday:20260904 ~other:20260903; + (* Before/after local midnight, independent of UTC midnight. *) + check ~today:(sample 1_786_204_740. 28_800.) ~yesterday:20260807 ~other:20260809; + check ~today:(sample 1_786_204_800. 28_800.) ~yesterday:20260808 ~other:20260807 +;; + +let test_detail_empty_navigation_title () = + let noop = Journal_view.Event.Handler.create (fun _ -> ()) in + let build () = Journal_header.detail ~capture_enabled:true ~on_capture:noop + ~copy_enabled:true ~on_copy:noop ~copy_feedback:None (Journal_view.View.text "Detail body") + |> Journal_view.View.Body.Private.to_widget + in + with_chrome_payloads () build (fun _ payloads -> + let detail = Hashtbl.fold (fun _ props found -> + if Yojson.Safe.Util.member "mode" props = `String "detail" then Some props else found) + payloads None |> Option.get in + Alcotest.(check string) "native Detail bar has an empty title" "" + (Yojson.Safe.Util.member "title" detail |> Yojson.Safe.Util.to_string)) +;; + let test_reactive_header_current_controls () = let props = Hashtbl.create 128 in let track_teardown = @@ -4729,7 +4896,14 @@ let () = Journal_bridge.register hooks) else Alcotest.run "application view" - [ ( "token control transport" + [ ( "relative date chrome", [ + Alcotest.test_case "calendar boundaries and date updates" `Quick test_timeline_relative_date_headers; + Alcotest.test_case "empty Detail navigation title" `Quick test_detail_empty_navigation_title; + Alcotest.test_case "foreground day updates before delayed or failed feed" `Quick + (fun () -> run_favorites_native_visibility ~calendar_change:`Day ()); + Alcotest.test_case "foreground zone updates before delayed or failed feed" `Quick + (fun () -> run_favorites_native_visibility ~calendar_change:`Zone ())] ) + ; ( "token control transport" , [ Alcotest.test_case "full data lane host response" `Quick