diff --git a/.claude/skills/uren-classificatie/classify-day.md b/.claude/skills/uren-classificatie/classify-day.md
index 3a5ee01..646edd8 100644
--- a/.claude/skills/uren-classificatie/classify-day.md
+++ b/.claude/skills/uren-classificatie/classify-day.md
@@ -3,18 +3,28 @@ Je bent een tijdregistratie-assistent die een developer helpt zijn werkuren te r
Datum: {{date}}
Voor elk genummerd item hieronder geef je één boekingsblok terug.
-- Vergadering-items: gebruik de vergader-duur voor startTime/endTime/hours
+- Vergadering-items: gebruik de vergader-duur voor startTime/endTime/hours. Geef ALTIJD een blok terug voor élk vergadering-item — de agenda heeft de hoogste prioriteit. Twijfel je over project/dienst, laat die dan null, maar laat het vergadering-blok nooit weg.
- Losse items: gebruik de browse-duur
Als er een sectie "Al geboekt vandaag" staat: dat werk is al geboekt. Maak daar GEEN blok of patternBlock voor — ook niet als een genummerd item of patroon ermee overeenkomt. Lees de omschrijvingen om te bepalen welk werk al gedekt is.
-Het doel is een gevulde werkdag van ~8 uur. De app plaatst de blokken zelf op de tijdlijn en vult de dag aan tot 8 uur met de "patternBlocks" hieronder — jij hoeft GEEN tijdstippen te bepalen.
+## Bronprioriteit
+De bronnen hebben een vaste rangorde: agenda > GitHub-commits > browser-historie > Linear > trends (historie van vorige weken). Een hogere bron is leidend bij het bepalen waar een blok over gaat. Trends zijn de láágste bron en dienen alleen om de dag aan te vullen — de app doet dat zelf (zie patternBlocks hieronder).
-Bouw daarom op basis van de historische boekingen een gerangschikte vul-lijst in "patternBlocks" (hoogste confidence eerst):
-- Een patroon is een combinatie van project+dienst die op vergelijkbare intervallen voorkomt (bijv. elke week, elke 2 weken). Matcht een patroon met de doeldatum ({{date}})? Geef het confidence 2–5 op basis van hoe sterk het patroon is.
-- Voeg DAARNAAST laag-zekere vul-kandidaten toe met confidence 1: de projecten/diensten die in de afgelopen week het meest voorkomen, als generieke "wat deze persoon waarschijnlijk ook deed"-blokken. Lever er ruim genoeg (samen makkelijk 8 uur) — de app gebruikt deze confidence-1 blokken ALLEEN als de dag anders niet aan 8 uur komt, en knipt het laatste blok op maat.
-- Gebruik het historisch gemiddelde voor de geschatte duur (estimatedHours); voor confidence-1 vulblokken is 1–2 uur prima.
+## Streng op relaties
+Koppel of voeg dingen ALLEEN samen als er concreet, benoembaar bewijs is dat ze bij elkaar horen:
+- dezelfde repo/hetzelfde project,
+- een gedeelde Linear-issue-verwijzing (bv. "GMS-4" in een commit-bericht, branch of agenda-titel),
+- of duidelijke trefwoord-overlap tussen titel, commit-bericht en agenda.
+Benoem dat bewijs kort in de "summary". Is er geen bewijs, koppel dan NIET: laat de activiteiten los van elkaar en vul relatedIssueIds met een lege array. Koppel nooit op alleen tijdsoverlap.
+
+## Vul-lijst (patternBlocks)
+De app vult de dag zelf aan tot ~8 uur: hij laat eerst de échte blokken van vandaag groeien naar hun historische omvang en voegt pas daarna losse vulblokken toe. De app bepaalt de duur én of een patroon sterk genoeg is — jij hoeft GEEN tijdstippen of uren-budget te bepalen.
+
+Lever in "patternBlocks" een gerangschikte lijst (hoogste confidence eerst) met de project+dienst-combinaties die op basis van de historische boekingen terugkeren, zodat de app ze een nette naam kan geven en kan inzetten als vulling:
+- Voeg een combinatie alleen toe als die in de historie regelmatig terugkomt. Pad niet met willekeurige combinaties.
- Voeg een combinatie NIET toe als die al in "blocks" voorkomt of al geboekt is.
+- estimatedHours mag je schatten op het historisch gemiddelde, maar de app overschrijft dit met zijn eigen berekening.
{{sections}}Beschikbare projecten:
{{projectList}}
@@ -24,12 +34,12 @@ Beschikbare diensten (gekoppeld aan projecten via projectId):
Geef een JSON-object terug met twee velden:
- "blocks": array van geclassificeerde items (één per genummerd blok hierboven)
-- "patternBlocks": array van extra blokken die puur op patroonherkenning zijn gebaseerd (kan leeg zijn)
+- "patternBlocks": array van terugkerende project+dienst-combinaties als vul-kandidaten (kan leeg zijn)
Elk item in "blocks" heeft:
- index (number, exact overeenkomend met het [N]-nummer hierboven)
- blockName (string, leesbare naam max 60 tekens, bv. "Standup — PR review")
-- summary (string, korte samenvatting wat er gedaan is, max 120 tekens, Nederlands)
+- summary (string, korte samenvatting wat er gedaan is, max 120 tekens, Nederlands; benoem hierin het relatie-bewijs als je iets koppelt)
- projectId (string | null, moet een van de beschikbare project-ID's zijn)
- serviceId (string | null, moet een dienst-ID zijn waarvan projectId overeenkomt)
- hourTypeId (string | null, moet een urensoort-id zijn uit de "urensoorten" van de gekozen dienst; vul deze ALTIJD in zodra je een dienst kiest. Kies de meest voor de hand liggende urensoort en bij twijfel de eerste in de lijst)
@@ -42,7 +52,7 @@ Elk item in "blocks" heeft:
1 = Onzeker — geen duidelijke match, vul in als best guess
Overweeg actief welke score van toepassing is. Geef niet standaard een hoge score.
-- relatedIssueIds (string[], identifiers van Linear issues die bij dit blok horen. Lege array als niets van toepassing.)
+- relatedIssueIds (string[], identifiers van Linear issues die bij dit blok horen — alleen bij concreet bewijs. Lege array als niets van toepassing.)
Elk item in "patternBlocks" heeft:
- blockName (string, leesbare naam max 60 tekens)
@@ -52,17 +62,15 @@ Elk item in "patternBlocks" heeft:
- hourTypeId (string | null, urensoort-id uit de gekozen dienst; ALTIJD invullen zodra er een dienst is)
- note (string, max 80 tekens)
- confidence (integer 1–5):
- 5 = Zeer zeker — patroon klopt exact en er is geen andere activiteit die het al dekt
+ 5 = Zeer zeker — sterk, frequent terugkerend patroon
4 = Zeker — sterk patroon, kleine twijfel
3 = Aannemelijk — patroon klopt, maar minder frequent of recent
2 = Twijfelachtig — zwak patroon, weinig historisch bewijs
- 1 = Vulblok — geen bewijs voor déze dag, puur een thema uit de afgelopen week om de dag tot 8 uur te vullen
-
-Geef echte patronen confidence 2–5 op basis van bewijs. Reserveer confidence 1 voor de generieke vulblokken; lever daar ruim genoeg van.
-- estimatedHours (number, schatting in uren op basis van historisch gemiddelde; voor confidence-1 vulblokken 1–2 uur)
+ 1 = Zeer zwak patroon
+- estimatedHours (number, schatting op basis van historisch gemiddelde; de app overschrijft dit)
- origin (altijd "llm-pattern")
BELANGRIJK: Voeg een combinatie van project+dienst ALLEEN toe aan "patternBlocks" als die NIET al in "blocks" voorkomt. Rangschik "patternBlocks" van hoogste naar laagste confidence.
Gebruik de cache-hints als leidraad maar overschrijf ze als de context duidelijk op een ander project wijst.
-Geef ALLEEN een geldig JSON-object terug, geen markdown, geen uitleg.
\ No newline at end of file
+Geef ALLEEN een geldig JSON-object terug, geen markdown, geen uitleg.
diff --git a/CONTEXT.md b/CONTEXT.md
index 21e3d4e..838cf3e 100644
--- a/CONTEXT.md
+++ b/CONTEXT.md
@@ -15,18 +15,36 @@ Time already booked for the day as a Simplicate `HourEntry`. Rendered blue ("geb
_Avoid_: entry (when ambiguous with concept block), booking
**Meeting block**:
-A concept block anchored to a calendar event; its start/end come from the event and are treated as fixed.
+A concept block anchored to a calendar event; its start/end come from the event and are treated as fixed. Calendar is the highest-priority source. A meeting counts as attended unless you explicitly **declined** it (un-RSVP'd `needsAction` meetings — recurring team rituals — are kept). Every such meeting **always** produces a block — even if the classifier omits it (it then falls back to the event title / cached mapping, or stays unclassified). Concurrent meetings keep their real times and overlap each other; the timeline renders them side by side, Google-Calendar style.
-**Fill candidate**:
-A concept block the LLM invents from recurring history (a project+service that recurs at regular intervals) rather than from observed activity. Origin `llm-pattern`. Returned as an ordered list with a confidence and `estimatedHours`. High-confidence candidates are genuine recurring work added regardless; the lowest-confidence ones (conf 1) are filler, pulled in only when the day is short of the fill target.
-_Avoid_: pattern block, filler block (both are now this one concept)
+**Source**:
+An origin of evidence about the day. The five sources, in fixed priority order, are: **calendar** > **GitHub commits** > **browser history** > **Linear** > **trends**. The first four are _observed activity_; trends are historical bookings from prior weeks. A higher source outranks a lower one when they describe the same work.
+_Avoid_: signal, input (when ambiguous)
+
+**Absorption**:
+A higher-priority source claiming related lower-priority activity that falls in its scope into a single concept block, instead of producing parallel blocks. A meeting absorbs the commits/browser activity in its window; a commit block absorbs the Linear issue it implements. Absorption only happens with **hard evidence** of a relationship (shared repo↔project mapping, a shared Linear issue ref, or clear keyword overlap) — never on time-overlap alone. Without evidence the activities stay separate blocks.
+
+**Project block**:
+The single concept block that all observed activity for one project+service on one day folds into. Several commit sessions / PR-merges and browser blocks on the same project+service become one block; the individual PRs/commits live on in its summary and note. Different services under the same project stay separate blocks (a service is a billable distinction), optionally grouped under a project header in the UI.
+_Avoid_: PR block, commit block (those are now folded into this)
+
+**Trends**:
+Historical bookings from prior weeks, used only to reach the fill target — never to create or relabel observed work. Trends fill in two ways, in order: first by **growing** the day's existing project blocks toward their historical size; then, only if still short, by adding loose fill blocks for **strong recurring patterns**. Trends are the lowest-priority source.
+_Avoid_: pattern block, filler block, fill candidate (all superseded by this term + Strong recurring pattern)
+
+**Strong recurring pattern**:
+A project+service booked in ≥ 3 of the last 4 weeks on a cadence that lands on the target date. This is the only trend allowed to introduce a project that had _no_ observed activity that day. Detected deterministically (counts + cadence + historical average duration computed in TypeScript); the LLM may select and label from the detected list but never invents one.
**Fill target**:
-The amount of booked time a day should reach — 8.0h total, counting existing hours and meeting blocks toward it. The packer fills the remainder with movable concept blocks, then with fill candidates until the target is met.
+The amount of booked time a day should reach — 8.0h total (a floor, not a ceiling), counting existing hours and meeting blocks toward it. The packer reaches it by first placing observed (absorbed, consolidated) blocks, then growing those blocks toward their historical share via trends, then adding strong-recurring-pattern fill blocks. Real work is never trimmed to stay under the target.
**Anchor**:
A block whose time is fixed and must not move during layout: meeting blocks and existing hours. Everything else is movable and gets repacked contiguously from 09:00 around the anchors.
+**Leftover block**:
+A classified block that was found but did not land on the timeline — either real observed work that overflowed past the day (no room left after the anchored meetings) or an LLM pattern/fill suggestion the packer didn't need. It is not discarded: it surfaces in a right-hand sidebar that auto-opens after _verwerken_ when leftovers exist and collapses to a rail with a count badge. From there each leftover can be added to the day, dismissed, or booked directly to Simplicate. (Already-booked duplicates are _not_ leftovers — they stay suppressed as noise.)
+_Avoid_: dropped block, overflow (when ambiguous with the timeline)
+
### The week
**Ingediende week** (submitted week):
diff --git a/docs/adr/0002-deterministic-day-packer.md b/docs/adr/0002-deterministic-day-packer.md
index 7e4c4cc..89dfb8b 100644
--- a/docs/adr/0002-deterministic-day-packer.md
+++ b/docs/adr/0002-deterministic-day-packer.md
@@ -1,5 +1,7 @@
# Deterministic day-packer: LLM classifies, TypeScript places
+> Partly amended by [ADR-0004](0004-deterministic-pattern-engine-and-grow-first-fill.md): pattern detection moved from the LLM to deterministic TS, and fill now grows real blocks before adding pattern blocks. The "LLM classifies, TS places" core below still holds.
+
The LLM returns only classification (name, project, service, confidence, fill candidates) — it never assigns block times. A deterministic TS packer (a domain use case) owns all time placement: it fixes anchors (meeting blocks + today's existing `HourEntry`s), drops concepts that duplicate an anchor, repacks the remaining movable blocks contiguously from 09:00 around the anchors, and pulls in ranked fill candidates until the day reaches an 8h fill target. 8h is a floor, not a ceiling: filler stops at 8h but real work is never dropped or trimmed.
This deliberately sacrifices fidelity to real activity timestamps for a clean, gap-free, full day — chosen because the user's priority is "create 8 bookable hours," not "show exactly when each thing happened." A future reader will see the LLM emit no times and a packer fabricate a tidy day that doesn't mirror the raw browser/calendar timeline; this is intentional.
diff --git a/docs/adr/0004-deterministic-pattern-engine-and-grow-first-fill.md b/docs/adr/0004-deterministic-pattern-engine-and-grow-first-fill.md
new file mode 100644
index 0000000..d625e71
--- /dev/null
+++ b/docs/adr/0004-deterministic-pattern-engine-and-grow-first-fill.md
@@ -0,0 +1,28 @@
+---
+status: accepted (amends ADR-0002)
+---
+
+# Deterministic pattern engine and grow-first fill
+
+[ADR-0002](0002-deterministic-day-packer.md) deliberately kept trend/pattern judgment in the LLM (it rejected a pure deterministic packer because it "can't fit something based on last week's themes when unsure"). In practice the LLM over-detected patterns — it eyeballed weekly intervals from a text list of historical bookings and emitted a large `patternBlocks` fill list, so days filled up mostly from trends and genuine observed work got drowned out. We are moving pattern detection to deterministic TypeScript (a **hybrid** engine) and changing how the day is filled to the target.
+
+The same reasoning ADR-0002 used to keep _placement_ deterministic ("LLMs are unreliable at no-overlap arithmetic and hitting exactly 8h") applies to pattern detection: "booked in ≥3 of the last 4 weeks on a matching cadence" and "historical average duration" are arithmetic, not judgment.
+
+## Decision
+
+- **Hybrid pattern engine.** TypeScript computes, per project+service over the last 4 weeks: occurrence count, whether its cadence lands on the target date, average duration, and historical daily share. The LLM may only _select and label_ from this computed list — it no longer invents `patternBlocks` or estimates durations.
+- **A trend is the lowest-priority source.** It never creates or relabels observed work. It only reaches the 8h fill target, in this order:
+ 1. **Grow first** — distribute the gap to 8h across the day's existing observed project blocks, proportional to each project+service's historical share (blocks with no history get an equal share).
+ 2. **Then strong-pattern fill** — only if still short, add loose fill blocks for **strong recurring patterns** (booked ≥3 of last 4 weeks on matching cadence). This is the only path by which a project with _no_ observed activity that day may appear.
+- The 8h target remains a floor, not a ceiling (unchanged from ADR-0002): observed work is never trimmed to stay under it.
+
+## Considered options
+
+- **Keep the LLM detecting patterns, just with stricter prompt rules** (count weeks, require cadence). Rejected: still arithmetic done by an LLM at temperature 0.1 over a truncated text list — the exact unreliability ADR-0002 cites for placement.
+- **Fully deterministic patterns, no LLM involvement.** Rejected: contextual fit ("does this recurring booking actually make sense today?") is judgment worth keeping, so the LLM selects/labels from the computed candidates.
+
+## Consequences
+
+- The `classify-day` prompt no longer asks for a ranked `patternBlocks` fill list with `estimatedHours`/confidence; that contract is replaced by a TS-computed candidate list the LLM picks from. The prompt skill (`uren-classificatie`) and `GeminiRepository.classifyDay` change accordingly.
+- `PackDayUseCase` gains the grow-first fill logic and the historical-share inputs; the old "ranked fill candidates until target" loop (and the ADR-0002 note that confidence ≥2 candidates are "added regardless") is superseded.
+- "Fill candidate" disappears from the domain language, replaced by **Trends** + **Strong recurring pattern** (see CONTEXT.md).
diff --git a/docs/design/leftover-sidebar-options.html b/docs/design/leftover-sidebar-options.html
new file mode 100644
index 0000000..2a2b228
--- /dev/null
+++ b/docs/design/leftover-sidebar-options.html
@@ -0,0 +1,763 @@
+
+
+
+ Vier verschillende manieren om de rechter "Niet geplaatst / Overgebleven" sidebar te tonen na Verwerk dag.
+ Elke optie staat in context naast een vereenvoudigde dagtijdlijn, met dezelfde voorbeelddata, en toont zowel de
+ open sidebar als de ingeklapte rail-staat (met telbadge). Kleuren, badges en confidence-stijlen zijn overgenomen
+ uit DayTimeline.tsx.
+
+
+
+
+
+
+
1
Classic drawer
+
+ Een ~300px paneel dat over de tijdlijn schuift, met volledige verticale kaarten die elk de drie acties inline tonen.
+ Veilige, herkenbare keuze: alles is direct zichtbaar zonder hover. Ingeklapt wordt het een 44px rail met verticaal
+ label "Niet geplaatst · 4" en telbadge.
+
+
+
+
+
+
+
+
maandag 2 juni
+
5u geboekt · 2 concepten te bevestigen
+
+
+
+
+
+
+
08
09
+
10
11
+
12
13
+
14
15
+
16
17
+
+
+
+
+
Haven operationeel 2026
08:00–10:00 · 2u
+
5/5
Standup & planning
10:00–11:30 · Harbase
+
Default urenpost 2026
13:00–16:00 · 3u
+
4/5
Klantcall n8n
16:00–17:00
+
Dag vol — overige blokken rechts geplaatst →
+
+
+
+
+
+
+
Niet geplaatst 4
+
+
+
+
+
+
+
+
Uren-assistent Gemini fix
+
Harbase Innovatieprogramma 2026
+
+ 5/5
+
+
~1,5u⏏ Paste niet meer in de dag
+
+
+
+
+
+
+
+
+
+
+
Monitoring & alerting tweaks
+
Haven operationeel 2026
+
+ 4/5
+
+
~1u⏏ Paste niet meer in de dag
+
+
+
+
+
+
+
+
+
+
+
Code review n8n PR's
+
Default urenpost 2026
+
+ 3/5
+
+
~1u✦ LLM-suggestie
+
+
+
+
+
+
+
+
+
+
+
Documentatie bijwerken
+
⚠ Project ontbreekt — klik om in te vullen
+
+ 1/5
+
+
~0,5u✦ Zwakke suggestie
+
+
+
+
+
+
+
+
+
+
+
+ Ingeklapt
+
+ 4
+ 📥
+ Niet geplaatst · 4
+
+
+
+
+
+
+
+
+
2
Compact chips / triage rail
+
+ Dichte, scanbare rij van chips (naam + tijd, kleur-swatch voor confidence). Acties verschijnen op hover, zodat de lijst
+ rustig blijft en je snel kunt triagen. Ideaal als er regelmatig veel leftovers zijn. Ingeklapt: smalle rail met alleen de badge.
+
+
+
+
+
+
+
maandag 2 juni
+
5u geboekt · 2 concepten te bevestigen
+
+
+
+
+
+
+
08
09
+
10
11
+
12
13
+
14
15
+
16
17
+
+
+
+
+
Haven operationeel 2026
08:00–10:00 · 2u
+
5/5
Standup & planning
10:00–11:30 · Harbase
+
Default urenpost 2026
13:00–16:00 · 3u
+
4/5
Klantcall n8n
16:00–17:00
+
Dag vol — overige blokken rechts geplaatst →
+
+
+
+
+
+
+
Overgebleven 4
+
+
+
+
+
+
+
+
+
+
+
Uren-assistent Gemini fix
+
~1,5u5/5overflow
+
+
+
+
+
+
+
+
+
+
+
+
Monitoring & alerting tweaks
+
~1u4/5overflow
+
+
+
+
+
+
+
+
+
+
+
+
Code review n8n PR's
+
~1u3/5suggestie
+
+
+
+
+
+
+
+
+
+
+
+
⚠ Documentatie bijwerken
+
~0,5u1/5project ontbreekt
+
+
+
+
+
+
+
+
hover over een chip voor acties
+
+
+
+
+ Ingeklapt
+
+ 4
+ ▣
+
+
+
+
+
+
+
+
+
3
Grouped by reason
+
+ Kaarten gegroepeerd onder reden-headers ("Paste niet in de dag (2)", "Suggesties (2)") met een sticky header die
+ een bulk-actie Boek alles biedt. Maakt de aard van elk blok meteen duidelijk en versnelt batch-verwerking.
+
+
+
+
+
+
+
maandag 2 juni
+
5u geboekt · 2 concepten te bevestigen
+
+
+
+
+
+
+
08
09
+
10
11
+
12
13
+
14
15
+
16
17
+
+
+
+
+
Haven operationeel 2026
08:00–10:00 · 2u
+
5/5
Standup & planning
10:00–11:30 · Harbase
+
Default urenpost 2026
13:00–16:00 · 3u
+
4/5
Klantcall n8n
16:00–17:00
+
Dag vol — overige blokken rechts geplaatst →
+
+
+
+
+
+
+
+
Niet geplaatst · 4 blokken
+
+
+
+
+
+
+
+
⏏ Paste niet in de dag (2)
+
+
+
Uren-assistent Gemini fix
Harbase Innovatieprogramma 2026
+ 5/5
+
+
~1,5u
+
+
+
+
Monitoring & alerting tweaks
Haven operationeel 2026
+ 4/5
+
+
~1u
+
+
+
+
✦ Suggesties (2)
+
+
+
Code review n8n PR's
Default urenpost 2026
+ 3/5
+
+
~1u
+
+
+
+
Documentatie bijwerken
⚠ Project ontbreekt
+ 1/5
+
+
~0,5u
+
+
+
+
+
+ Ingeklapt
+
+ 4
+ ☰
+ Overgebleven · 4
+
+
+
+
+
+
+
+
+
4
Inbox / card-stack
+
+ Grotere, e-mail-inbox-achtige kaarten met een prominente primaire Boek-knop, secundaire Toevoegen en
+ subtiele Negeer. Een samenvattende header ("4 blokken · 4,5u niet geplaatst") geeft direct overzicht. Comfortabel
+ één-voor-één afhandelen, met de booking-actie als duidelijk hoofddoel.
+
Vier varianten voor hoe overlappende agenda-events in de dag-tijdlijn renderen. Zelfde data, zelfde kleur-/confidence-taal als de app. Doel: geen events meer die naar een verre smalle kolom worden geslingerd, plus iets meer verticale lucht tussen blokken (Google-Calendar-gevoel). Kies de beste.
+
+
+
+
geboekt
+
hoog (4–5)
+
midden (3) / project ontbreekt
+
laag (1–2)
+
+
+
+
+
+ Optie 1
+
Google-Calendar split-within-band
+
Overlappende events delen de breedte 50/50 — maar alleen over het overlappende stuk. Niet-overlappende blokken blijven volle breedte. De canonieke GCal-aanpak: voorspelbaar, beide titels altijd leesbaar, niets vliegt naar een verre kolom.
+
+
+
+
+
+
+
+ Optie 2
+
Cascade / gestapelde kaarten
+
Overlappende events blijven vrijwel volle breedte maar worden ~28px naar rechts verschoven en met z-index + zachte schaduw gelaagd (laatste bovenop). Voelt als gestapelde kaarten; beide titels lezen langs de linkerrand.
+
+
+
+
+
+
+
+ Optie 3
+
Dominant + peek
+
Het langere/eerdere event houdt het grootste deel van de breedte; het gelijktijdige kortere event overlapt de rechterkant op ~62% breedte met een linkerschaduw, zodat beide titels leesbaar blijven. Hiërarchisch: het "hoofd"-blok blijft dominant.
+
+
+
+
+
+
+
+ Optie 4 · eigen variant
+
Connected pair (volle breedte, gesplitst label)
+
Geen offset of smalle kolommen: het overlap-venster wordt verticaal in twee leesbare helften gedeeld binnen één volle-breedte band, met een dunne scheidingslijn. Maximale leesbaarheid bij precies twee overlappers; valt elegant terug op volle breedte zodra de overlap voorbij is.
+
+
+
+
+
+
+
diff --git a/src/domain/entities/ClassifiedBlock.ts b/src/domain/entities/ClassifiedBlock.ts
index 1fe4aa2..44564a0 100644
--- a/src/domain/entities/ClassifiedBlock.ts
+++ b/src/domain/entities/ClassifiedBlock.ts
@@ -18,4 +18,10 @@ export interface ClassifiedBlock extends HistoryBlock {
rawUrls?: string[]
commits?: GitHubCommit[]
linearIssues?: LinearIssue[]
+ // Set when classification found this block but the packer didn't place it on the
+ // timeline (see CONTEXT.md "Leftover block"). Such blocks live in the sidebar,
+ // not the day. `leftoverReason` records why: 'overflow' (real work that ran past
+ // the day) or 'suggestion' (an LLM pattern the day didn't need).
+ unplaced?: boolean
+ leftoverReason?: 'overflow' | 'suggestion'
}
diff --git a/src/domain/usecases/GroupAndClassifyDayUseCase.test.ts b/src/domain/usecases/GroupAndClassifyDayUseCase.test.ts
index 39c4a3f..2f483d6 100644
--- a/src/domain/usecases/GroupAndClassifyDayUseCase.test.ts
+++ b/src/domain/usecases/GroupAndClassifyDayUseCase.test.ts
@@ -40,7 +40,10 @@ const makeResult = (index: number, overrides: Partial =
})
const projects: Project[] = [{ id: 'proj-1', name: 'Project One' }]
-const services: Service[] = [{ id: 'svc-1', name: 'Service One', projectId: 'proj-1' }]
+const services: Service[] = [
+ { id: 'svc-1', name: 'Service One', projectId: 'proj-1' },
+ { id: 'svc-2', name: 'Service Two', projectId: 'proj-1' },
+]
function makeDeps(cacheAll: Record = {}, classifyDayResults: DayClassificationResult[] = []) {
const copilotRepo: ICopilotRepository = {
@@ -112,12 +115,54 @@ describe('GroupAndClassifyDayUseCase', () => {
expect(result[0]!.origin).toBe('llm')
})
+ it('still emits a meeting block when the LLM omits it (calendar is guaranteed)', async () => {
+ const event = makeEvent({ title: 'Sprint review' })
+ // LLM returns no results at all — the meeting must not vanish.
+ const { copilotRepo, cacheRepo } = makeDeps({}, [])
+ const useCase = new GroupAndClassifyDayUseCase(copilotRepo, cacheRepo, projects, services)
+ const result = await useCase.execute('2024-01-15', [], [event])
+
+ expect(result).toHaveLength(1)
+ expect(result[0]!.blockName).toBe('Sprint review') // falls back to the event title
+ expect(result[0]!.overlappingMeetings).toHaveLength(1)
+ expect(result[0]!.projectId).toBeUndefined() // unclassified — user fills it in
+ })
+
+ it('falls back to the cached mapping for a meeting the LLM omits', async () => {
+ const event = makeEvent({ title: 'All Hands' })
+ const cacheAll = { 'All Hands:_solo': { projectId: 'proj-1', serviceId: 'svc-1', note: 'wekelijks' } }
+ const { copilotRepo, cacheRepo } = makeDeps(cacheAll, [])
+ const useCase = new GroupAndClassifyDayUseCase(copilotRepo, cacheRepo, projects, services)
+ const result = await useCase.execute('2024-01-15', [], [event])
+
+ expect(result).toHaveLength(1)
+ expect(result[0]!.projectId).toBe('proj-1')
+ expect(result[0]!.serviceId).toBe('svc-1')
+ })
+
+ it('consolidates two standalone blocks on the same project+service into one', async () => {
+ const blockA = makeBlock({ urlPattern: 'github.com/org/repo@09:00', firstVisitTime: '09:00', lastVisitTime: '10:00', hours: 1 })
+ const blockB = makeBlock({ urlPattern: 'github.com/org/repo@14:00', firstVisitTime: '14:00', lastVisitTime: '15:00', hours: 1 })
+ const { copilotRepo, cacheRepo } = makeDeps({}, [
+ makeResult(0, { blockName: 'PR A', serviceId: 'svc-1' }),
+ makeResult(1, { blockName: 'PR B', serviceId: 'svc-1' }),
+ ])
+ const useCase = new GroupAndClassifyDayUseCase(copilotRepo, cacheRepo, projects, services)
+ const result = await useCase.execute('2024-01-15', [blockA, blockB], [])
+
+ expect(result).toHaveLength(1)
+ expect(result[0]!.hours).toBe(2)
+ expect(result[0]!.firstVisitTime).toBe('09:00')
+ expect(result[0]!.lastVisitTime).toBe('15:00')
+ })
+
it('sort order — output sorted by startTime ascending', async () => {
const blockA = makeBlock({ urlPattern: 'a.com', firstVisitTime: '14:00', lastVisitTime: '14:30' })
const blockB = makeBlock({ urlPattern: 'b.com', firstVisitTime: '09:00', lastVisitTime: '09:30' })
+ // Distinct services so consolidation keeps them as two separate blocks.
const { copilotRepo, cacheRepo } = makeDeps({}, [
- makeResult(0, { blockName: 'Block A' }),
- makeResult(1, { blockName: 'Block B' }),
+ makeResult(0, { blockName: 'Block A', serviceId: 'svc-1' }),
+ makeResult(1, { blockName: 'Block B', serviceId: 'svc-2' }),
])
const useCase = new GroupAndClassifyDayUseCase(copilotRepo, cacheRepo, projects, services)
const result = await useCase.execute('2024-01-15', [blockA, blockB], [])
diff --git a/src/domain/usecases/GroupAndClassifyDayUseCase.ts b/src/domain/usecases/GroupAndClassifyDayUseCase.ts
index 79d2e8f..59d8072 100644
--- a/src/domain/usecases/GroupAndClassifyDayUseCase.ts
+++ b/src/domain/usecases/GroupAndClassifyDayUseCase.ts
@@ -1,4 +1,4 @@
-import type { ICopilotRepository, DayItem, Project, Service } from '../repositories/ICopilotRepository'
+import type { ICopilotRepository, DayItem, Project, Service, DayClassificationResult } from '../repositories/ICopilotRepository'
import type { IMappingCacheRepository } from '../repositories/IMappingCacheRepository'
import type { CalendarEvent } from '../entities/CalendarEvent'
import type { HistoryBlock } from '../entities/HistoryBlock'
@@ -7,6 +7,8 @@ import type { ClassifiedBlock } from '../entities/ClassifiedBlock'
import type { DayContext } from '../entities/DayContext'
import { attachHistoryToMeetings } from './attachHistoryToMeetings'
import { toConfidenceScore } from './toConfidenceScore'
+import { mappingCacheKey } from './mappingCacheKey'
+import { consolidateByProjectService } from './consolidateByProjectService'
function sanitizeUrl(url: string): string {
try {
@@ -70,14 +72,14 @@ export class GroupAndClassifyDayUseCase {
undefined,
)
const cacheKey = dominant
- ? `${group.event.title}:${dominant.urlPattern}`
+ ? `${group.event.title}:${mappingCacheKey(dominant.urlPattern)}`
: `${group.event.title}:_solo`
items.push({ kind: 'meeting', index, event: group.event, historyBlocks: group.historyBlocks, cacheKey })
index++
}
for (const block of unclaimed) {
- items.push({ kind: 'standalone', index, block, cacheKey: block.urlPattern })
+ items.push({ kind: 'standalone', index, block, cacheKey: mappingCacheKey(block.urlPattern) })
index++
}
@@ -166,51 +168,68 @@ export class GroupAndClassifyDayUseCase {
}))
const regularResults = results.filter(r => r.isPatternBlock !== true)
+ const resultByIndex = new Map(
+ regularResults.map(r => [r.index, r]),
+ )
+ // Calendar is the highest-priority source: every meeting item ALWAYS yields a
+ // block, even when the LLM omits its index from the response. Use the LLM
+ // result when present, else the cached mapping for this recurring meeting,
+ // else leave it unclassified so the user can fill it in — never silently
+ // drop a meeting.
+ for (const item of llmItems) {
+ if (item.kind !== 'meeting') continue
+ const result = resultByIndex.get(item.index)
+ const cached = allCache[item.cacheKey]
+ const event = item.event
+ const hBlocks = item.historyBlocks
+ const meetingUrls = hBlocks.flatMap(b => b.urls)
+ const meetingTitles = hBlocks.flatMap(b => b.titles)
+ const classified: ClassifiedBlock = {
+ date,
+ urlPattern: item.cacheKey,
+ urls: meetingUrls,
+ titles: meetingTitles,
+ visitCount: hBlocks.reduce((sum, b) => sum + b.visitCount, 0),
+ firstVisitTime: toTime(event.start),
+ lastVisitTime: toTime(event.end),
+ hours: roundToHalf((event.end.getTime() - event.start.getTime()) / 3_600_000),
+ blockName: result?.blockName ?? event.title,
+ summary: result?.summary ?? '',
+ startTime: toTime(event.start),
+ endTime: toTime(event.end),
+ note: result?.note ?? cached?.note ?? '',
+ confidence: result ? toConfidenceScore(result.confidence) : 1,
+ origin: 'llm',
+ overlappingMeetings: [event],
+ rawTitles: meetingTitles.slice(0, 5),
+ rawUrls: meetingUrls.slice(0, 5).map(sanitizeUrl),
+ ...(context?.commits !== undefined ? { commits: context.commits } : {}),
+ ...((() => {
+ if (!context?.linearIssues?.length) return {}
+ if (result?.relatedIssueIds && result.relatedIssueIds.length > 0) {
+ const relatedSet = new Set(result.relatedIssueIds)
+ return { linearIssues: context.linearIssues.filter(i => relatedSet.has(i.identifier)) }
+ }
+ return { linearIssues: context.linearIssues }
+ })()),
+ }
+ const projectId = result?.projectId ?? cached?.projectId
+ const serviceId = result?.serviceId ?? cached?.serviceId
+ if (projectId != null) classified.projectId = projectId
+ if (serviceId != null) classified.serviceId = serviceId
+ const ht = this.resolveHourTypeId(classified.serviceId, result?.hourTypeId)
+ if (ht !== undefined) classified.hourTypeId = ht
+ llmResults.push(classified)
+ }
+
+ // Standalone items stay LLM-result-driven: the model may legitimately skip
+ // low-signal browser noise, so we only emit a block when it classified one.
for (const result of regularResults) {
const matchedItem = llmItems.find(i => i.index === result.index)
- if (!matchedItem) continue
+ if (!matchedItem || matchedItem.kind !== 'standalone') continue
- if (matchedItem.kind === 'meeting') {
- const event = matchedItem.event
- const hBlocks = matchedItem.historyBlocks
- const meetingUrls = hBlocks.flatMap(b => b.urls)
- const meetingTitles = hBlocks.flatMap(b => b.titles)
- const classified: ClassifiedBlock = {
- date,
- urlPattern: matchedItem.cacheKey,
- urls: meetingUrls,
- titles: meetingTitles,
- visitCount: hBlocks.reduce((sum, b) => sum + b.visitCount, 0),
- firstVisitTime: toTime(event.start),
- lastVisitTime: toTime(event.end),
- hours: roundToHalf((event.end.getTime() - event.start.getTime()) / 3_600_000),
- blockName: result.blockName,
- summary: result.summary,
- startTime: toTime(event.start),
- endTime: toTime(event.end),
- note: result.note,
- confidence: toConfidenceScore(result.confidence),
- origin: 'llm',
- overlappingMeetings: [event],
- rawTitles: meetingTitles.slice(0, 5),
- rawUrls: meetingUrls.slice(0, 5).map(sanitizeUrl),
- ...(context?.commits !== undefined ? { commits: context.commits } : {}),
- ...((() => {
- if (!context?.linearIssues?.length) return {}
- if (result.relatedIssueIds && result.relatedIssueIds.length > 0) {
- const relatedSet = new Set(result.relatedIssueIds)
- return { linearIssues: context.linearIssues.filter(i => relatedSet.has(i.identifier)) }
- }
- return { linearIssues: context.linearIssues }
- })()),
- }
- if (result.projectId !== null) classified.projectId = result.projectId
- if (result.serviceId !== null) classified.serviceId = result.serviceId
- const ht = this.resolveHourTypeId(classified.serviceId, result.hourTypeId)
- if (ht !== undefined) classified.hourTypeId = ht
- llmResults.push(classified)
- } else {
+ {
const block = matchedItem.block
// Voor commit-blocks: filter commits op deze repo + tijdsperiode
@@ -271,8 +290,13 @@ export class GroupAndClassifyDayUseCase {
}
}
+ // Fold observed activity for one project+service into a single Project block
+ // (multiple PR-merges / commit sessions / browser blocks → one block). Meeting
+ // blocks and fill candidates are left untouched (see consolidateByProjectService).
+ const observed = consolidateByProjectService([...cacheResults, ...llmResults])
+
const coveredProjectService = new Set(
- [...cacheResults, ...llmResults]
+ observed
.filter(b => b.projectId && b.serviceId)
.map(b => `${b.projectId}__${b.serviceId}`)
)
@@ -280,7 +304,7 @@ export class GroupAndClassifyDayUseCase {
b => !b.projectId || !b.serviceId || !coveredProjectService.has(`${b.projectId}__${b.serviceId}`)
)
- const all = [...cacheResults, ...llmResults, ...dedupedPatterns]
+ const all = [...observed, ...dedupedPatterns]
all.sort((a, b) => a.startTime.localeCompare(b.startTime))
return all
}
diff --git a/src/domain/usecases/PackDayUseCase.test.ts b/src/domain/usecases/PackDayUseCase.test.ts
index 8017843..abbdd69 100644
--- a/src/domain/usecases/PackDayUseCase.test.ts
+++ b/src/domain/usecases/PackDayUseCase.test.ts
@@ -3,6 +3,26 @@ import { PackDayUseCase } from './PackDayUseCase'
import type { ClassifiedBlock } from '../entities/ClassifiedBlock'
import type { HourEntry } from '../entities/HourEntry'
import type { CalendarEvent } from '../entities/CalendarEvent'
+import type { TrendPattern, TrendPatternsResult } from './computeTrendPatterns'
+
+/** Build a TrendPatternsResult from terse pattern specs for packer tests. */
+const makeTrends = (
+ specs: { projectId: string; serviceId: string; avg: number; share: number; weeks?: number; strong?: boolean }[],
+): TrendPatternsResult => {
+ const patterns: TrendPattern[] = specs.map(s => ({
+ projectId: s.projectId,
+ serviceId: s.serviceId,
+ weeksPresent: s.weeks ?? (s.strong ? 4 : 1),
+ daysActive: 4,
+ avgDurationHours: s.avg,
+ historicalShare: s.share,
+ cadenceMatchesTarget: s.strong ?? false,
+ isStrong: s.strong ?? false,
+ }))
+ const byKey = new Map(patterns.map(p => [`${p.projectId}__${p.serviceId}`, p]))
+ const strong = patterns.filter(p => p.isStrong)
+ return { patterns, byKey, strong }
+}
const makeBlock = (overrides: Partial = {}): ClassifiedBlock => ({
date: '2024-01-15',
@@ -92,26 +112,29 @@ describe('PackDayUseCase', () => {
expect(result[1]!.endTime).toBe('10:30')
})
- it('repacks meeting blocks contiguously too (meetings are not fixed)', () => {
- const work = makeBlock({ blockName: 'Work', hours: 1, startTime: '08:00', endTime: '09:00' })
+ it('anchors meeting blocks at their real calendar times and flows work around them', () => {
+ const work = makeBlock({ blockName: 'Work', hours: 0.5, startTime: '08:00', endTime: '08:30' })
const meeting = makeMeeting('09:30', '10:00', { blockName: 'Standup', hours: 0.5 })
- const result = new PackDayUseCase().execute([work, meeting], [], { targetHours: 1.5 })
+ const result = new PackDayUseCase().execute([work, meeting], [], { targetHours: 1 })
const byName = Object.fromEntries(result.map(r => [r.blockName, r]))
- // Earlier original start wins order: Work (08:00) then Standup (09:30), packed from 09:00.
+ // Meeting keeps its real time; work is placed from 09:00 in the gap before it.
+ expect(byName['Standup']!.startTime).toBe('09:30')
+ expect(byName['Standup']!.endTime).toBe('10:00')
expect(byName['Work']!.startTime).toBe('09:00')
- expect(byName['Work']!.endTime).toBe('10:00')
- expect(byName['Standup']!.startTime).toBe('10:00')
- expect(byName['Standup']!.endTime).toBe('10:30')
+ expect(byName['Work']!.endTime).toBe('09:30')
})
- it('moves a meeting block that overlaps a booked entry to after it', () => {
- const entry = makeEntry('09:00', '11:00', 2, { projectId: 'other', projectServiceId: 'other' })
- const meeting = makeMeeting('09:30', '10:00', { blockName: 'Overleg', hours: 0.5 })
- const result = new PackDayUseCase().execute([meeting], [entry], { targetHours: 8 })
+ it('keeps overlapping meetings at their real times (rendered side by side)', () => {
+ const a = makeMeeting('10:00', '11:00', { blockName: 'Projectoverleg', hours: 1, projectId: 'p1', serviceId: 's1' })
+ const b = makeMeeting('10:00', '10:30', { blockName: 'FinOps', hours: 0.5, projectId: 'p2', serviceId: 's2' })
+ const result = new PackDayUseCase().execute([a, b], [], { targetHours: 8 })
- const m = result.find(r => r.blockName === 'Overleg')!
- expect(minutes(m.startTime)).toBeGreaterThanOrEqual(minutes('11:00'))
+ const byName = Object.fromEntries(result.map(r => [r.blockName, r]))
+ expect(byName['Projectoverleg']!.startTime).toBe('10:00')
+ expect(byName['Projectoverleg']!.endTime).toBe('11:00')
+ expect(byName['FinOps']!.startTime).toBe('10:00') // not shifted away from the overlap
+ expect(byName['FinOps']!.endTime).toBe('10:30')
})
it('never overlaps an existing booked entry', () => {
@@ -123,12 +146,20 @@ describe('PackDayUseCase', () => {
expect(minutes(w.startTime)).toBeGreaterThanOrEqual(minutes('10:00'))
})
- it('drops a concept that duplicates an existing entry (same project+service, overlapping time)', () => {
+ it('drops a non-meeting concept that duplicates an existing entry (same project+service, overlapping time)', () => {
const entry = makeEntry('10:00', '11:00', 1) // proj-1 / svc-1
- const dup = makeMeeting('10:00', '11:00', { blockName: 'ISO GAP overleg', projectId: 'proj-1', serviceId: 'svc-1' })
+ const dup = makeBlock({ blockName: 'ISO GAP werk', startTime: '10:00', endTime: '11:00', projectId: 'proj-1', serviceId: 'svc-1' })
const result = new PackDayUseCase().execute([dup], [entry], { targetHours: 1 })
- expect(result.find(r => r.blockName === 'ISO GAP overleg')).toBeUndefined()
+ expect(result.find(r => r.blockName === 'ISO GAP werk')).toBeUndefined()
+ })
+
+ it('never drops a meeting, even when it duplicates an existing booked entry', () => {
+ const entry = makeEntry('10:00', '11:00', 1) // proj-1 / svc-1
+ const meeting = makeMeeting('10:00', '11:00', { blockName: 'ISO GAP overleg', projectId: 'proj-1', serviceId: 'svc-1' })
+ const result = new PackDayUseCase().execute([meeting], [entry], { targetHours: 1 })
+
+ expect(result.find(r => r.blockName === 'ISO GAP overleg')).toBeDefined()
})
it('does not return existing entries as blocks (only concepts)', () => {
@@ -137,42 +168,80 @@ describe('PackDayUseCase', () => {
expect(result).toEqual([])
})
- it('counts existing hours toward the 8h target so filler stops early', () => {
- const entry = makeEntry('09:00', '16:30', 7.5, { projectId: 'other', projectServiceId: 'other' })
- const filler = makeCandidate(1, 2, { blockName: 'Filler' })
- const result = new PackDayUseCase().execute([filler], [entry], { targetHours: 8 })
+ it('grows an observed block toward its historical average to fill the day', () => {
+ const observed = makeBlock({ blockName: 'Observed', hours: 1 }) // proj-1/svc-1, 1h real work
+ const trends = makeTrends([{ projectId: 'proj-1', serviceId: 'svc-1', avg: 8, share: 1 }])
+ const result = new PackDayUseCase().execute([observed], [], { targetHours: 8, trends })
- const f = result.find(r => r.blockName === 'Filler')!
- expect(f).toBeDefined()
- // only 0.5h remained to reach 8h, so filler is trimmed to 0.5h
- expect(f.hours).toBe(0.5)
+ const o = result.find(r => r.blockName === 'Observed')!
+ expect(o.hours).toBe(8) // grown from 1h up to its historical average
+ expect(result.reduce((s, r) => s + r.hours, 0)).toBe(8)
})
- it('fills with candidates highest-confidence first, trimming the last to land on target', () => {
- const observed = makeBlock({ blockName: 'Observed', hours: 1 }) // 1h real work
- const genuine = makeCandidate(3, 2, { blockName: 'Genuine', projectId: 'p2', serviceId: 's2' })
- const filler = makeCandidate(1, 4, { blockName: 'Filler', projectId: 'p3', serviceId: 's3' })
- const result = new PackDayUseCase().execute([observed, genuine, filler], [], { targetHours: 4 })
+ it('caps growth at the historical average, then fills the rest with a strong pattern', () => {
+ const observed = makeBlock({ blockName: 'Observed', hours: 1 }) // proj-1/svc-1
+ const fill = makeCandidate(1, 99, { blockName: 'Recurring', projectId: 'p2', serviceId: 's2' })
+ const trends = makeTrends([
+ { projectId: 'proj-1', serviceId: 'svc-1', avg: 3, share: 0.5 }, // room to grow: 2h
+ { projectId: 'p2', serviceId: 's2', avg: 5, share: 0.5, strong: true },
+ ])
+ const result = new PackDayUseCase().execute([observed, fill], [], { targetHours: 8, trends })
const byName = Object.fromEntries(result.map(r => [r.blockName, r]))
- expect(byName['Observed']).toBeDefined()
- expect(byName['Genuine']!.hours).toBe(2) // conf 3 placed first
- // 1 + 2 = 3h booked, 1h short of 4h target → conf 1 filler trimmed to 1h
- expect(byName['Filler']!.hours).toBe(1)
- expect(result.reduce((s, r) => s + r.hours, 0)).toBe(4)
+ expect(byName['Observed']!.hours).toBe(3) // grown 1h → 3h (capped at avg), not further
+ expect(byName['Recurring']!.hours).toBe(5) // strong-pattern fill sized at its avg
+ expect(result.reduce((s, r) => s + r.hours, 0)).toBe(8)
})
- it('never adds a fill candidate once the target is met by real work, regardless of confidence', () => {
+ it('never grows or fills once real work already meets the target (floor, not ceiling)', () => {
const observed = makeBlock({ blockName: 'Observed', hours: 4 })
- const hiConf = makeCandidate(5, 2, { blockName: 'HiConf', projectId: 'p2', serviceId: 's2' })
- const filler = makeCandidate(1, 2, { blockName: 'Filler', projectId: 'p3', serviceId: 's3' })
- const result = new PackDayUseCase().execute([observed, hiConf, filler], [], { targetHours: 4 })
+ const fill = makeCandidate(5, 2, { blockName: 'Recurring', projectId: 'p2', serviceId: 's2' })
+ const trends = makeTrends([
+ { projectId: 'proj-1', serviceId: 'svc-1', avg: 10, share: 0.5 },
+ { projectId: 'p2', serviceId: 's2', avg: 2, share: 0.5, strong: true },
+ ])
+ const result = new PackDayUseCase().execute([observed, fill], [], { targetHours: 4, trends })
- expect(result.find(r => r.blockName === 'HiConf')).toBeUndefined()
- expect(result.find(r => r.blockName === 'Filler')).toBeUndefined()
+ expect(result.find(r => r.blockName === 'Observed')!.hours).toBe(4) // not grown past target
+ expect(result.find(r => r.blockName === 'Recurring')).toBeUndefined()
expect(result.reduce((s, r) => s + r.hours, 0)).toBe(4)
})
+ it('distributes growth across blocks proportional to historical share', () => {
+ const a = makeBlock({ blockName: 'A', hours: 1, projectId: 'proj-1', serviceId: 'svc-1' })
+ const b = makeBlock({ blockName: 'B', hours: 1, projectId: 'p2', serviceId: 's2' })
+ const trends = makeTrends([
+ { projectId: 'proj-1', serviceId: 'svc-1', avg: 99, share: 0.75 },
+ { projectId: 'p2', serviceId: 's2', avg: 99, share: 0.25 },
+ ])
+ // gap = 8 - (1 + 1) = 6 → split 0.75/0.25 → A +4.5 (→5.5), B +1.5 (→2.5)
+ const result = new PackDayUseCase().execute([a, b], [], { targetHours: 8, trends })
+ const byName = Object.fromEntries(result.map(r => [r.blockName, r]))
+ expect(byName['A']!.hours).toBeCloseTo(5.5)
+ expect(byName['B']!.hours).toBeCloseTo(2.5)
+ expect(result.reduce((s, r) => s + r.hours, 0)).toBeCloseTo(8)
+ })
+
+ it('never grows a meeting block beyond its calendar duration', () => {
+ const meeting = makeMeeting('10:00', '11:00', { blockName: 'Mtg', hours: 1, projectId: 'proj-1', serviceId: 'svc-1' })
+ const trends = makeTrends([{ projectId: 'proj-1', serviceId: 'svc-1', avg: 8, share: 1 }])
+ const result = new PackDayUseCase().execute([meeting], [], { targetHours: 8, trends })
+ expect(result.find(r => r.blockName === 'Mtg')!.hours).toBe(1)
+ })
+
+ it('does not fill from a weak (non-strong) pattern even when the day is short', () => {
+ const observed = makeBlock({ blockName: 'Observed', hours: 1 })
+ const weak = makeCandidate(2, 4, { blockName: 'Weak', projectId: 'p2', serviceId: 's2' })
+ const trends = makeTrends([
+ // observed combo absent from trends → no growth; weak pattern is not strong → no fill
+ { projectId: 'p2', serviceId: 's2', avg: 4, share: 1, strong: false },
+ ])
+ const result = new PackDayUseCase().execute([observed, weak], [], { targetHours: 8, trends })
+
+ expect(result.find(r => r.blockName === 'Weak')).toBeUndefined()
+ expect(result.find(r => r.blockName === 'Observed')!.hours).toBe(1) // no trend → not grown
+ })
+
it('keeps all real work even when it exceeds the target (floor, not ceiling)', () => {
const a = makeBlock({ blockName: 'A', hours: 5 })
const b = makeBlock({ blockName: 'B', hours: 5, projectId: 'p2', serviceId: 's2' })
@@ -182,10 +251,11 @@ describe('PackDayUseCase', () => {
expect(total).toBe(10)
})
- it('drops a fill candidate whose project+service is already booked today', () => {
+ it('does not fill a strong pattern whose project+service is already booked today', () => {
const entry = makeEntry('09:00', '10:00', 1) // proj-1 / svc-1
const candidate = makeCandidate(3, 2, { blockName: 'Dup pattern', projectId: 'proj-1', serviceId: 'svc-1' })
- const result = new PackDayUseCase().execute([candidate], [entry], { targetHours: 8 })
+ const trends = makeTrends([{ projectId: 'proj-1', serviceId: 'svc-1', avg: 2, share: 1, strong: true }])
+ const result = new PackDayUseCase().execute([candidate], [entry], { targetHours: 8, trends })
expect(result.find(r => r.blockName === 'Dup pattern')).toBeUndefined()
})
@@ -211,6 +281,45 @@ describe('PackDayUseCase', () => {
expect(w.endTime).toBe('13:00')
})
+ it('overflows movable work that cannot start before dayEnd into leftovers', () => {
+ // A meeting fills 09:00–18:00; no room left before dayEnd. The work block
+ // can't start before 18:00 → it overflows to the sidebar, not the timeline.
+ const meeting = makeMeeting('09:00', '18:00', { blockName: 'Allday workshop', hours: 9 })
+ const work = makeBlock({ blockName: 'Late work', hours: 2, projectId: 'p2', serviceId: 's2' })
+ const { placed, leftovers } = new PackDayUseCase().executeWithLeftovers([meeting, work], [], { targetHours: 8 })
+
+ expect(placed.find(b => b.blockName === 'Allday workshop')).toBeDefined()
+ expect(placed.find(b => b.blockName === 'Late work')).toBeUndefined()
+ const lo = leftovers.find(b => b.blockName === 'Late work')!
+ expect(lo).toBeDefined()
+ expect(lo.unplaced).toBe(true)
+ expect(lo.leftoverReason).toBe('overflow')
+ })
+
+ it('never overflows a meeting, even one running past dayEnd', () => {
+ const meeting = makeMeeting('17:00', '19:00', { blockName: 'Evening sync', hours: 2 })
+ const { placed, leftovers } = new PackDayUseCase().executeWithLeftovers([meeting], [], { targetHours: 8 })
+ expect(placed.find(b => b.blockName === 'Evening sync')).toBeDefined()
+ expect(leftovers).toHaveLength(0)
+ })
+
+ it('surfaces unused LLM suggestions as leftovers, deduped by project+service', () => {
+ const observed = makeBlock({ blockName: 'Observed', hours: 8 }) // fills the day, proj-1/svc-1
+ const usedKey = makeCandidate(3, 1, { blockName: 'Dup of observed', projectId: 'proj-1', serviceId: 'svc-1' })
+ const suggestion = makeCandidate(3, 1, { blockName: 'Idle pattern', projectId: 'p9', serviceId: 's9' })
+ const trends = makeTrends([{ projectId: 'p9', serviceId: 's9', avg: 1, share: 0.2 }])
+ const { placed, leftovers } = new PackDayUseCase().executeWithLeftovers(
+ [observed, usedKey, suggestion], [], { targetHours: 8, trends },
+ )
+
+ // proj-1/svc-1 is covered by the observed block → not a leftover.
+ expect(leftovers.find(b => b.blockName === 'Dup of observed')).toBeUndefined()
+ const s = leftovers.find(b => b.blockName === 'Idle pattern')!
+ expect(s).toBeDefined()
+ expect(s.leftoverReason).toBe('suggestion')
+ expect(placed.find(b => b.origin === 'llm-pattern')).toBeUndefined() // none needed (day full)
+ })
+
it('output is sorted by startTime ascending', () => {
const meeting = makeMeeting('11:00', '12:00', { blockName: 'Mtg' })
const work = makeBlock({ blockName: 'Work', hours: 1 })
diff --git a/src/domain/usecases/PackDayUseCase.ts b/src/domain/usecases/PackDayUseCase.ts
index 36e6c10..eecabb2 100644
--- a/src/domain/usecases/PackDayUseCase.ts
+++ b/src/domain/usecases/PackDayUseCase.ts
@@ -1,5 +1,6 @@
import type { ClassifiedBlock } from '../entities/ClassifiedBlock'
import type { HourEntry } from '../entities/HourEntry'
+import type { TrendPatternsResult } from './computeTrendPatterns'
export interface PackDayOptions {
/** Target booked hours for the day. Existing entries + meetings count toward it. Default 8. */
@@ -8,6 +9,18 @@ export interface PackDayOptions {
dayStart?: string
/** Minute grid that start/end times snap to. Default 5. */
gridMinutes?: number
+ /**
+ * End of the visible day. Movable work that can't start before this becomes a
+ * leftover ('overflow') instead of being placed off-screen. Default '18:00'.
+ */
+ dayEnd?: string
+ /**
+ * Deterministic trend data (see ADR-0004). When supplied, the day is filled to
+ * the target by first GROWING observed project blocks toward their historical
+ * size, then adding fill blocks only for strong recurring patterns. When
+ * omitted, no growth or fill happens — observed blocks are placed as-is.
+ */
+ trends?: TrendPatternsResult
}
interface Interval {
@@ -56,23 +69,77 @@ function nextFreeStart(from: number, duration: number, occupied: Interval[]): nu
return cursor
}
+const serviceKey = (b: { projectId?: string; serviceId?: string }): string => `${b.projectId}__${b.serviceId}`
+
+const isMeeting = (b: ClassifiedBlock): boolean => !!b.overlappingMeetings && b.overlappingMeetings.length > 0
+
+/**
+ * Water-fills `gap` hours across growable blocks, proportional to each block's
+ * weight, capped at each block's available room. Returns per-block growth (same
+ * order as input) and the leftover gap that couldn't be absorbed.
+ */
+function distributeGrowth(
+ items: { weight: number; room: number }[],
+ gap: number,
+): { growth: number[]; leftover: number } {
+ const growth = items.map(() => 0)
+ let remaining = gap
+ let active = items.map((it, i) => (it.weight > EPSILON && it.room - growth[i]! > EPSILON ? i : -1)).filter(i => i >= 0)
+
+ // At most one round per item: each round caps at least one block (or distributes the rest).
+ for (let round = 0; round <= items.length && remaining > EPSILON && active.length > 0; round++) {
+ const sumW = active.reduce((s, i) => s + items[i]!.weight, 0)
+ if (sumW <= EPSILON) break
+ let distributed = 0
+ for (const i of active) {
+ const want = (remaining * items[i]!.weight) / sumW
+ const room = items[i]!.room - growth[i]!
+ const add = Math.min(want, room)
+ growth[i]! += add
+ distributed += add
+ }
+ remaining -= distributed
+ if (distributed <= EPSILON) break
+ active = active.filter(i => items[i]!.room - growth[i]! > EPSILON)
+ }
+
+ return { growth, leftover: remaining }
+}
+
/**
* Lays a day's classified blocks onto the timeline so it reads as a clean, gap-free, ~8h day.
*
- * - Anchors (meeting blocks + today's existing entries) keep their fixed times.
+ * - Anchors keep their fixed times: today's existing entries AND meeting blocks (calendar is the
+ * highest-priority source). Meetings may overlap each other — the timeline renders concurrent
+ * meetings side by side. Movable blocks (work, fill) flow around the union of all anchors.
* - Concepts that duplicate an existing entry are dropped.
- * - Movable blocks are repacked contiguously from `dayStart`, flowing around anchors.
- * - Fill candidates (origin 'llm-pattern') top the day up to `targetHours`: confidence >= 2
- * is genuine recurring work added regardless; confidence 1 is filler used only to reach the
- * target (the last one trimmed to land exactly on it).
+ * - With `trends` (ADR-0004): the gap to the target is filled FIRST by growing observed
+ * project blocks proportional to their historical share (capped at their historical average
+ * per-day duration), THEN — only if still short — by fill blocks for strong recurring patterns
+ * (≥3 of 4 weeks) whose project+service had no activity today.
+ * - 8h is a floor, not a ceiling: real work is never trimmed.
*
* Existing entries are NOT returned — they're already booked; the packer only positions concepts.
*/
+export interface PackedDay {
+ /** Blocks laid onto the timeline. */
+ placed: ClassifiedBlock[]
+ /** Blocks classification found but the packer couldn't place — for the sidebar. */
+ leftovers: ClassifiedBlock[]
+}
+
export class PackDayUseCase {
+ /** Backwards-compatible: returns only the placed blocks. */
execute(blocks: ClassifiedBlock[], existingEntries: HourEntry[], options: PackDayOptions = {}): ClassifiedBlock[] {
+ return this.executeWithLeftovers(blocks, existingEntries, options).placed
+ }
+
+ executeWithLeftovers(blocks: ClassifiedBlock[], existingEntries: HourEntry[], options: PackDayOptions = {}): PackedDay {
const targetHours = options.targetHours ?? 8
const dayStartMin = timeToMinutes(options.dayStart ?? '09:00')
+ const dayEndMin = timeToMinutes(options.dayEnd ?? '18:00')
const grid = options.gridMinutes ?? 5
+ const trends = options.trends
const snapDuration = (hours: number): number =>
Math.max(grid, Math.round((hours * 60) / grid) * grid)
@@ -94,60 +161,142 @@ export class PackDayUseCase {
const isTimedConceptDuplicate = (b: ClassifiedBlock): boolean => {
if (!b.projectId || !b.serviceId) return false
- const entries = entriesByService.get(`${b.projectId}__${b.serviceId}`)
+ const entries = entriesByService.get(serviceKey(b))
if (!entries) return false
const bs = timeToMinutes(b.startTime)
const be = timeToMinutes(b.endTime)
return entries.some(e => overlaps(bs, be, timeToMinutes(e.startTime), timeToMinutes(e.endTime)))
}
- const keptConcepts = concepts.filter(b => !isTimedConceptDuplicate(b))
- // Fill candidates have no real time: drop if their project+service is already booked at all today.
- const keptCandidates = candidates.filter(b => !b.projectId || !b.serviceId || !entriesByService.has(`${b.projectId}__${b.serviceId}`))
+ // Meetings are never dropped — calendar is the highest-priority source and the
+ // user manually deselects what shouldn't be booked. Only non-meeting concepts
+ // are deduplicated against already-booked entries.
+ const keptConcepts = concepts.filter(b => isMeeting(b) || !isTimedConceptDuplicate(b))
- // --- Occupied zones: only the already-booked entries are fixed ---
- let occupied: Interval[] = mergeIntervals(
- existingEntries.map(e => ({ start: timeToMinutes(e.startTime), end: timeToMinutes(e.endTime) })),
- )
+ // Calendar is the highest-priority source: meeting blocks are anchored at
+ // their real event times and never repacked. They may overlap each other —
+ // the timeline renders concurrent meetings side by side (assignBlockColumns),
+ // Google-Calendar style. Everything else is movable and flows around them.
+ const anchoredMeetings = keptConcepts.filter(isMeeting)
+ const movableConcepts = keptConcepts.filter(b => !isMeeting(b))
+
+ const anchorHours = existingEntries.reduce((s, e) => s + e.hours, 0)
+ const meetingHours = anchoredMeetings.reduce((s, b) => s + b.hours, 0)
+ const movableObservedHours = movableConcepts.reduce((s, b) => s + b.hours, 0)
+ const gap = targetHours - anchorHours - meetingHours - movableObservedHours
+
+ // --- Grow phase: distribute the gap across observed (movable) project blocks ---
+ // A meeting's duration is fixed by its calendar event, so meetings never grow.
+ // A block without a historical average can't be sized, so it doesn't grow either.
+ const grownMovable: ClassifiedBlock[] = movableConcepts.map(b => ({ ...b }))
+ let leftoverGap = Math.max(0, gap)
+
+ if (trends && gap > EPSILON) {
+ const growable = grownMovable
+ .map((b, i) => ({ b, i }))
+ .filter(({ b }) => b.projectId && b.serviceId && trends.byKey.has(serviceKey(b)))
+
+ if (growable.length > 0) {
+ const items = growable.map(({ b }) => {
+ const pattern = trends.byKey.get(serviceKey(b))!
+ const ceiling = Math.max(b.hours, pattern.avgDurationHours)
+ return { weight: pattern.historicalShare, room: ceiling - b.hours }
+ })
+ const { growth, leftover } = distributeGrowth(items, gap)
+ growable.forEach(({ i }, k) => {
+ grownMovable[i]!.hours += growth[k]!
+ })
+ leftoverGap = leftover
+ }
+ }
+
+ // --- Occupied zones: booked entries AND anchored meetings are fixed ---
+ // Movable blocks flow around the union of both; meetings keep their own times
+ // even where they overlap each other.
+ let occupied: Interval[] = mergeIntervals([
+ ...existingEntries.map(e => ({ start: timeToMinutes(e.startTime), end: timeToMinutes(e.endTime) })),
+ ...anchoredMeetings.map(m => ({ start: timeToMinutes(m.startTime), end: timeToMinutes(m.endTime) })),
+ ])
let cursor = dayStartMin
- const place = (hours: number): { startTime: string; endTime: string; minutes: number } => {
+ // Returns null when the block can't start before the day ends (overflow).
+ const place = (hours: number): { startTime: string; endTime: string; minutes: number } | null => {
const durMin = snapDuration(hours)
const start = nextFreeStart(cursor, durMin, occupied)
+ if (start >= dayEndMin) return null
occupied = mergeIntervals([...occupied, { start, end: start + durMin }])
cursor = start + durMin
return { startTime: minutesToTime(start), endTime: minutesToTime(start + durMin), minutes: durMin }
}
- // --- Repack concept blocks contiguously, preserving chronological order, around booked hours ---
- const placedConcepts = [...keptConcepts]
- .sort((a, b) => timeToMinutes(a.startTime) - timeToMinutes(b.startTime))
- .map(b => {
- const { startTime, endTime } = place(b.hours)
- return { ...b, startTime, endTime }
- })
-
- let bookedHours =
- existingEntries.reduce((s, e) => s + e.hours, 0) +
- placedConcepts.reduce((s, b) => s + b.hours, 0)
-
- // --- Top up to the target with fill candidates (highest confidence first) ---
- // Fill candidates are invented, not observed, so they NEVER push the day past
- // the target: once observed work + anchors reach it, none are added. The last
- // one placed is trimmed so the day lands exactly on the target.
+ const leftovers: ClassifiedBlock[] = []
+
+ // Anchored meetings keep their real calendar times unchanged — never leftovers.
+ const placedMeetings = anchoredMeetings.map(b => ({ ...b }))
+
+ // --- Place movable concept blocks (with any growth) contiguously around fixed zones ---
+ // Work that can no longer start before the day ends overflows to the sidebar
+ // rather than being laid off-screen.
+ const placedConcepts: ClassifiedBlock[] = []
+ for (const b of [...grownMovable].sort((a, b) => timeToMinutes(a.startTime) - timeToMinutes(b.startTime))) {
+ const slot = place(b.hours)
+ if (slot === null) {
+ leftovers.push({ ...b, unplaced: true, leftoverReason: 'overflow' })
+ } else {
+ placedConcepts.push({ ...b, startTime: slot.startTime, endTime: slot.endTime, hours: slot.minutes / 60 })
+ }
+ }
+
+ // --- Fill the remaining gap with strong recurring patterns only ---
+ // A fill block is added only when the gap couldn't be absorbed by growing real
+ // work, and only for a project+service that (a) is a strong recurring pattern,
+ // (b) had no observed activity today, and (c) isn't already booked.
const placedCandidates: ClassifiedBlock[] = []
- const sortedCandidates = [...keptCandidates].sort((a, b) => b.confidence - a.confidence)
- for (const c of sortedCandidates) {
- const remaining = targetHours - bookedHours
- if (remaining <= EPSILON) break
- const trimmedHours = Math.min(c.hours, remaining)
- const { startTime, endTime, minutes } = place(trimmedHours)
- placedCandidates.push({ ...c, startTime, endTime, hours: minutes / 60 })
- bookedHours += minutes / 60
+ if (trends && leftoverGap > EPSILON) {
+ const coveredKeys = new Set(
+ keptConcepts.filter(b => b.projectId && b.serviceId).map(serviceKey),
+ )
+ const eligible = candidates
+ .filter(c => c.projectId && c.serviceId)
+ .filter(c => !coveredKeys.has(serviceKey(c)))
+ .filter(c => !entriesByService.has(serviceKey(c)))
+ .filter(c => trends.byKey.get(serviceKey(c))?.isStrong === true)
+ .sort((a, b) => {
+ const pa = trends.byKey.get(serviceKey(a))!
+ const pb = trends.byKey.get(serviceKey(b))!
+ return pb.weeksPresent - pa.weeksPresent || pb.historicalShare - pa.historicalShare
+ })
+
+ for (const c of eligible) {
+ if (leftoverGap <= EPSILON) break
+ const pattern = trends.byKey.get(serviceKey(c))!
+ const wanted = Math.min(pattern.avgDurationHours, leftoverGap)
+ const slot = place(wanted)
+ if (slot === null) break // day is full — remaining candidates surface as suggestions below
+ placedCandidates.push({ ...c, startTime: slot.startTime, endTime: slot.endTime, hours: slot.minutes / 60 })
+ leftoverGap -= slot.minutes / 60
+ }
}
- return [...placedConcepts, ...placedCandidates].sort(
+ const placed = [...placedMeetings, ...placedConcepts, ...placedCandidates].sort(
(a, b) => timeToMinutes(a.startTime) - timeToMinutes(b.startTime),
)
+
+ // --- Unused LLM suggestions become leftovers ---
+ // Any candidate the day didn't need surfaces in the sidebar, deduped by
+ // project+service against what's placed, already booked, or already collected.
+ const placedKeys = new Set(
+ placed.filter(b => b.projectId && b.serviceId).map(serviceKey),
+ )
+ const seenSuggestion = new Set()
+ for (const c of candidates) {
+ if (!c.projectId || !c.serviceId) continue
+ const key = serviceKey(c)
+ if (placedKeys.has(key) || entriesByService.has(key) || seenSuggestion.has(key)) continue
+ seenSuggestion.add(key)
+ leftovers.push({ ...c, unplaced: true, leftoverReason: 'suggestion' })
+ }
+
+ return { placed, leftovers }
}
}
diff --git a/src/domain/usecases/ProcessDayUseCase.ts b/src/domain/usecases/ProcessDayUseCase.ts
index 7416907..0c7da34 100644
--- a/src/domain/usecases/ProcessDayUseCase.ts
+++ b/src/domain/usecases/ProcessDayUseCase.ts
@@ -14,7 +14,9 @@ import { FetchLinearContextUseCase } from './FetchLinearContextUseCase'
import { GroupAndClassifyDayUseCase } from './GroupAndClassifyDayUseCase'
import { GetActiveProjectsForDateUseCase, type ActiveProjectsResult } from './GetActiveProjectsForDateUseCase'
import { groupCommitsIntoBlocks } from './GroupCommitsIntoBlocks'
+import { groupLinearIssuesIntoBlocks } from './groupLinearIssuesIntoBlocks'
import { PackDayUseCase } from './PackDayUseCase'
+import { computeTrendPatterns } from './computeTrendPatterns'
export interface ProcessDayProgress {
phase: 'fetching-context' | 'classifying-day' | 'done' | 'error'
@@ -116,7 +118,10 @@ export class ProcessDayUseCase {
yield { phase: 'classifying-day', date }
const commitBlocks = groupCommitsIntoBlocks(allCommits, date)
- const allBlocks = [...historyBlocks, ...commitBlocks]
+ // Completed Linear issues not already explained by a commit become their
+ // own blocks (Linear is the 4th source, above trends — see ADR/CONTEXT).
+ const linearBlocks = groupLinearIssuesIntoBlocks(linearIssues, allCommits, date)
+ const allBlocks = [...historyBlocks, ...commitBlocks, ...linearBlocks]
// Services for the day's active projects, with their hour types — the
// global store keeps no services, so load (or slice from the prefetch) here.
@@ -146,9 +151,15 @@ export class ProcessDayUseCase {
existingEntries,
)
- const packed = new PackDayUseCase().execute(classified, existingEntries)
+ // Deterministic trend patterns from the 28-day history window drive both
+ // growth (proportional to historical share) and strong-pattern fill — see
+ // ADR-0004. The LLM no longer decides what gets filled or how big.
+ const trends = computeTrendPatterns(activeProjectsResult.historicalEntries, date)
+ const { placed, leftovers } = new PackDayUseCase().executeWithLeftovers(classified, existingEntries, { trends })
- await this.historyStore.setBlocksForDate(date, packed)
+ // Leftovers ride alongside placed blocks (tagged `unplaced`); the UI routes
+ // placed → timeline, unplaced → the leftover sidebar.
+ await this.historyStore.setBlocksForDate(date, [...placed, ...leftovers])
} catch (err) {
yield {
phase: 'error',
diff --git a/src/domain/usecases/computeTrendPatterns.test.ts b/src/domain/usecases/computeTrendPatterns.test.ts
new file mode 100644
index 0000000..a534d30
--- /dev/null
+++ b/src/domain/usecases/computeTrendPatterns.test.ts
@@ -0,0 +1,92 @@
+import { describe, it, expect } from 'vitest'
+import { computeTrendPatterns, trendPatternKey } from './computeTrendPatterns'
+import type { HourEntry } from '../entities/HourEntry'
+
+const TARGET = '2026-06-01' // Monday
+
+function entry(startDate: string, hours: number, projectId = 'P1', serviceId = 'S1', note = ''): HourEntry {
+ return {
+ employeeId: 'E1',
+ projectId,
+ projectServiceId: serviceId,
+ hourTypeId: 'H1',
+ hours,
+ startDate,
+ startTime: '09:00',
+ endTime: '10:00',
+ note,
+ }
+}
+
+describe('computeTrendPatterns', () => {
+ it('flags a combo present in 4 of 4 weeks on the target weekday as strong', () => {
+ const entries = [
+ entry('2026-05-25', 2),
+ entry('2026-05-18', 2),
+ entry('2026-05-11', 2),
+ entry('2026-05-04', 2),
+ ]
+ const { byKey } = computeTrendPatterns(entries, TARGET)
+ const p = byKey.get(trendPatternKey('P1', 'S1'))!
+ expect(p.weeksPresent).toBe(4)
+ expect(p.cadenceMatchesTarget).toBe(true)
+ expect(p.isStrong).toBe(true)
+ expect(p.avgDurationHours).toBe(2)
+ })
+
+ it('does not flag a combo present in only 2 weeks as strong', () => {
+ const entries = [entry('2026-05-25', 2), entry('2026-05-18', 2)]
+ const { byKey } = computeTrendPatterns(entries, TARGET)
+ const p = byKey.get(trendPatternKey('P1', 'S1'))!
+ expect(p.weeksPresent).toBe(2)
+ expect(p.isStrong).toBe(false)
+ })
+
+ it('treats near-daily work as cadence-matching even off the target weekday', () => {
+ // 3 weeks, ~3 active days each, none on a Monday → strong via near-daily rule.
+ const entries = [
+ // week 0 (Tue/Wed/Thu before target)
+ entry('2026-05-26', 2), entry('2026-05-27', 2), entry('2026-05-28', 2),
+ // week 1
+ entry('2026-05-19', 2), entry('2026-05-20', 2), entry('2026-05-21', 2),
+ // week 2
+ entry('2026-05-12', 2), entry('2026-05-13', 2), entry('2026-05-14', 2),
+ ]
+ const { byKey } = computeTrendPatterns(entries, TARGET)
+ const p = byKey.get(trendPatternKey('P1', 'S1'))!
+ expect(p.weeksPresent).toBe(3)
+ expect(p.daysActive).toBe(9)
+ expect(p.cadenceMatchesTarget).toBe(true)
+ expect(p.isStrong).toBe(true)
+ })
+
+ it("excludes the target day's own entries and anything older than 28 days", () => {
+ const entries = [
+ entry(TARGET, 5), // today — observed, not a trend
+ entry('2026-04-27', 5), // 35 days back — outside window
+ entry('2026-05-25', 2), // valid
+ ]
+ const { byKey } = computeTrendPatterns(entries, TARGET)
+ const p = byKey.get(trendPatternKey('P1', 'S1'))!
+ expect(p.daysActive).toBe(1)
+ expect(p.weeksPresent).toBe(1)
+ expect(p.historicalShare).toBe(1)
+ })
+
+ it('computes historical share across combos and sorts by it', () => {
+ const entries = [
+ entry('2026-05-25', 6, 'P1', 'S1'),
+ entry('2026-05-25', 2, 'P2', 'S2'),
+ ]
+ const { patterns } = computeTrendPatterns(entries, TARGET)
+ expect(patterns[0]!.projectId).toBe('P1')
+ expect(patterns[0]!.historicalShare).toBeCloseTo(0.75)
+ expect(patterns[1]!.historicalShare).toBeCloseTo(0.25)
+ })
+
+ it('returns nothing for an empty window', () => {
+ const { patterns, strong } = computeTrendPatterns([], TARGET)
+ expect(patterns).toEqual([])
+ expect(strong).toEqual([])
+ })
+})
diff --git a/src/domain/usecases/computeTrendPatterns.ts b/src/domain/usecases/computeTrendPatterns.ts
new file mode 100644
index 0000000..d4518d0
--- /dev/null
+++ b/src/domain/usecases/computeTrendPatterns.ts
@@ -0,0 +1,130 @@
+import type { HourEntry } from '../entities/HourEntry'
+
+/**
+ * A recurring project+service pattern derived deterministically from the last 4
+ * weeks of bookings. This replaces the LLM's eyeballed pattern detection (see
+ * ADR-0004): counting weeks and averaging durations is arithmetic, so TypeScript
+ * owns it. The LLM may only select and label from these candidates.
+ */
+export interface TrendPattern {
+ projectId: string
+ serviceId: string
+ /** Distinct weeks (of the last 4 before the target date) this combo appears in. */
+ weeksPresent: number
+ /** Distinct days this combo was booked on in the window. */
+ daysActive: number
+ /** Typical hours booked per active day — used to size growth and fill blocks. */
+ avgDurationHours: number
+ /** This combo's share of all booked hours in the window (0..1). Weights proportional growth. */
+ historicalShare: number
+ /** True when the pattern plausibly recurs on the target date (near-daily, or same weekday ≥2 weeks). */
+ cadenceMatchesTarget: boolean
+ /** weeksPresent ≥ 3 AND cadence matches — the only patterns allowed to introduce a no-activity project as fill. */
+ isStrong: boolean
+}
+
+export interface TrendPatternsResult {
+ /** All combos, highest historical share first. */
+ patterns: TrendPattern[]
+ /** Lookup by `${projectId}__${serviceId}`. */
+ byKey: Map
+ /** Strong recurring patterns only, strongest first. */
+ strong: TrendPattern[]
+}
+
+const WINDOW_DAYS = 28
+
+function parseDate(dateStr: string): Date {
+ const [y, m, d] = dateStr.split('-').map(Number)
+ return new Date(y!, m! - 1, d!)
+}
+
+/** Whole days from `earlier` to `later` (positive when `later` is after `earlier`). */
+function daysBetween(earlier: string, later: string): number {
+ const ms = parseDate(later).getTime() - parseDate(earlier).getTime()
+ return Math.round(ms / 86_400_000)
+}
+
+function key(projectId: string, serviceId: string): string {
+ return `${projectId}__${serviceId}`
+}
+
+/**
+ * Derives recurring project+service patterns from historical bookings.
+ *
+ * Only the 28 days strictly before `targetDate` count — the target day's own
+ * entries are observed activity, not a trend. Weeks are 7-day buckets counting
+ * back from the target.
+ */
+export function computeTrendPatterns(entries: HourEntry[], targetDate: string): TrendPatternsResult {
+ const targetWeekday = parseDate(targetDate).getDay()
+
+ interface Acc {
+ projectId: string
+ serviceId: string
+ totalHours: number
+ weeks: Set
+ days: Set
+ targetWeekdayWeeks: Set
+ }
+ const accByKey = new Map()
+
+ for (const e of entries) {
+ const diff = daysBetween(e.startDate, targetDate)
+ // diff <= 0: the target day itself or the future — not a trend.
+ // diff > WINDOW_DAYS: outside the 4-week window.
+ if (diff <= 0 || diff > WINDOW_DAYS) continue
+
+ const week = Math.floor((diff - 1) / 7) // 0..3
+ const k = key(e.projectId, e.projectServiceId)
+ let acc = accByKey.get(k)
+ if (!acc) {
+ acc = {
+ projectId: e.projectId,
+ serviceId: e.projectServiceId,
+ totalHours: 0,
+ weeks: new Set(),
+ days: new Set(),
+ targetWeekdayWeeks: new Set(),
+ }
+ accByKey.set(k, acc)
+ }
+ acc.totalHours += e.hours
+ acc.weeks.add(week)
+ acc.days.add(e.startDate)
+ if (parseDate(e.startDate).getDay() === targetWeekday) acc.targetWeekdayWeeks.add(week)
+ }
+
+ const totalHoursAll = [...accByKey.values()].reduce((s, a) => s + a.totalHours, 0)
+
+ const patterns: TrendPattern[] = [...accByKey.values()].map(a => {
+ const weeksPresent = a.weeks.size
+ const daysActive = a.days.size
+ // Near-daily work (≥3 active days/week on average) plausibly recurs on any
+ // weekday; otherwise require the same weekday in at least 2 of the weeks.
+ const nearDaily = daysActive >= weeksPresent * 3
+ const sameWeekday = a.targetWeekdayWeeks.size >= 2
+ const cadenceMatchesTarget = nearDaily || sameWeekday
+ return {
+ projectId: a.projectId,
+ serviceId: a.serviceId,
+ weeksPresent,
+ daysActive,
+ avgDurationHours: a.totalHours / daysActive,
+ historicalShare: totalHoursAll > 0 ? a.totalHours / totalHoursAll : 0,
+ cadenceMatchesTarget,
+ isStrong: weeksPresent >= 3 && cadenceMatchesTarget,
+ }
+ })
+
+ patterns.sort((a, b) => b.historicalShare - a.historicalShare)
+
+ const byKey = new Map(patterns.map(p => [key(p.projectId, p.serviceId), p]))
+ const strong = patterns
+ .filter(p => p.isStrong)
+ .sort((a, b) => b.weeksPresent - a.weeksPresent || b.historicalShare - a.historicalShare)
+
+ return { patterns, byKey, strong }
+}
+
+export const trendPatternKey = key
diff --git a/src/domain/usecases/consolidateByProjectService.test.ts b/src/domain/usecases/consolidateByProjectService.test.ts
new file mode 100644
index 0000000..5bd1504
--- /dev/null
+++ b/src/domain/usecases/consolidateByProjectService.test.ts
@@ -0,0 +1,86 @@
+import { describe, it, expect } from 'vitest'
+import { consolidateByProjectService } from './consolidateByProjectService'
+import type { ClassifiedBlock } from '../entities/ClassifiedBlock'
+import type { CalendarEvent } from '../entities/CalendarEvent'
+
+function block(over: Partial = {}): ClassifiedBlock {
+ return {
+ date: '2026-06-01',
+ urlPattern: 'github.com/acme/web',
+ urls: ['github.com/acme/web'],
+ titles: ['PR: thing'],
+ visitCount: 1,
+ firstVisitTime: '09:00',
+ lastVisitTime: '10:00',
+ hours: 1,
+ blockName: 'Web work',
+ summary: 'did web work',
+ startTime: '09:00',
+ endTime: '10:00',
+ note: 'web',
+ confidence: 3,
+ origin: 'llm',
+ projectId: 'P1',
+ serviceId: 'S1',
+ ...over,
+ }
+}
+
+describe('consolidateByProjectService', () => {
+ it('merges two blocks on the same project+service into one, summing hours', () => {
+ const result = consolidateByProjectService([
+ block({ urlPattern: 'github.com/acme/web@09:00', titles: ['PR: A'], hours: 1, startTime: '09:00', firstVisitTime: '09:00', lastVisitTime: '10:00', confidence: 3, visitCount: 2 }),
+ block({ urlPattern: 'github.com/acme/web@14:00', titles: ['PR: B'], hours: 2, startTime: '14:00', firstVisitTime: '14:00', lastVisitTime: '16:00', confidence: 4, visitCount: 3 }),
+ ])
+ expect(result).toHaveLength(1)
+ const m = result[0]!
+ expect(m.hours).toBe(3)
+ expect(m.visitCount).toBe(5)
+ expect(m.titles).toEqual(expect.arrayContaining(['PR: A', 'PR: B']))
+ expect(m.confidence).toBe(4)
+ expect(m.firstVisitTime).toBe('09:00')
+ expect(m.lastVisitTime).toBe('16:00')
+ expect(m.blockName).toContain('(+1)')
+ })
+
+ it('keeps different services under the same project separate', () => {
+ const result = consolidateByProjectService([
+ block({ serviceId: 'S1', hours: 1 }),
+ block({ serviceId: 'S2', hours: 2 }),
+ ])
+ expect(result).toHaveLength(2)
+ })
+
+ it('never merges meeting blocks', () => {
+ const event = { title: 'Standup', start: new Date(), end: new Date(), attendees: [] } as unknown as CalendarEvent
+ const result = consolidateByProjectService([
+ block({ overlappingMeetings: [event], hours: 0.5 }),
+ block({ overlappingMeetings: [event], hours: 0.5 }),
+ ])
+ expect(result).toHaveLength(2)
+ })
+
+ it('leaves fill candidates (llm-pattern) untouched', () => {
+ const result = consolidateByProjectService([
+ block({ origin: 'llm-pattern', hours: 1 }),
+ block({ origin: 'llm-pattern', hours: 1 }),
+ ])
+ expect(result).toHaveLength(2)
+ })
+
+ it('passes through blocks missing a project or service', () => {
+ /* eslint-disable @typescript-eslint/no-unused-vars -- omit project/service via rest */
+ const { projectId: _p, ...noProject } = block({ hours: 1 })
+ const { serviceId: _s, ...noService } = block({ hours: 1 })
+ /* eslint-enable @typescript-eslint/no-unused-vars */
+ const result = consolidateByProjectService([noProject, noService])
+ expect(result).toHaveLength(2)
+ })
+
+ it('leaves a single block unchanged', () => {
+ const input = block({ blockName: 'Solo' })
+ const result = consolidateByProjectService([input])
+ expect(result).toHaveLength(1)
+ expect(result[0]!.blockName).toBe('Solo')
+ })
+})
diff --git a/src/domain/usecases/consolidateByProjectService.ts b/src/domain/usecases/consolidateByProjectService.ts
new file mode 100644
index 0000000..0a8e724
--- /dev/null
+++ b/src/domain/usecases/consolidateByProjectService.ts
@@ -0,0 +1,96 @@
+import type { ClassifiedBlock } from '../entities/ClassifiedBlock'
+import type { GitHubCommit } from '../entities/GitHubCommit'
+import type { LinearIssue } from '../entities/LinearIssue'
+
+/**
+ * Folds all observed activity for one project+service on a day into a single
+ * "Project block" (see CONTEXT.md). Several commit sessions / PR-merges and
+ * browser blocks on the same project+service become one block; the individual
+ * PRs/commits survive in its titles, summary and note.
+ *
+ * Excluded from merging:
+ * - Meeting blocks (they carry `overlappingMeetings`): each calendar event is a
+ * distinct anchored block with its own time and name.
+ * - Fill candidates (origin 'llm-pattern'): not observed activity.
+ * - Blocks without both a project and a service: nothing to key on.
+ *
+ * Different services under the same project stay separate — a service is a
+ * billable distinction and a booking targets exactly one.
+ */
+export function consolidateByProjectService(blocks: ClassifiedBlock[]): ClassifiedBlock[] {
+ const isMergeable = (b: ClassifiedBlock): boolean =>
+ b.origin !== 'llm-pattern' &&
+ !(b.overlappingMeetings && b.overlappingMeetings.length > 0) &&
+ !!b.projectId &&
+ !!b.serviceId
+
+ const groups = new Map()
+ const passthrough: ClassifiedBlock[] = []
+
+ for (const b of blocks) {
+ if (!isMergeable(b)) {
+ passthrough.push(b)
+ continue
+ }
+ const key = `${b.projectId}__${b.serviceId}`
+ const list = groups.get(key) ?? []
+ list.push(b)
+ groups.set(key, list)
+ }
+
+ const merged: ClassifiedBlock[] = []
+ for (const list of groups.values()) {
+ merged.push(list.length === 1 ? list[0]! : mergeGroup(list))
+ }
+
+ return [...passthrough, ...merged].sort((a, b) => a.startTime.localeCompare(b.startTime))
+}
+
+function uniq(values: string[]): string[] {
+ return [...new Set(values.filter(Boolean))]
+}
+
+function mergeGroup(group: ClassifiedBlock[]): ClassifiedBlock {
+ // Dominant block: most hours, then most visits. Its identity (name, urlPattern,
+ // hourType, origin) seeds the merged block.
+ const dominant = [...group].sort(
+ (a, b) => b.hours - a.hours || b.visitCount - a.visitCount,
+ )[0]!
+
+ const sorted = [...group].sort((a, b) => a.startTime.localeCompare(b.startTime))
+ const titles = uniq(group.flatMap(b => b.titles)).slice(0, 12)
+ const urls = uniq(group.flatMap(b => b.urls)).slice(0, 10)
+ const rawTitles = uniq(group.flatMap(b => b.rawTitles ?? [])).slice(0, 5)
+ const rawUrls = uniq(group.flatMap(b => b.rawUrls ?? [])).slice(0, 5)
+
+ const commits: GitHubCommit[] = group.flatMap(b => b.commits ?? [])
+ const linearById = new Map()
+ for (const issue of group.flatMap(b => b.linearIssues ?? [])) {
+ linearById.set(issue.identifier, issue)
+ }
+ const linearIssues = [...linearById.values()]
+
+ const summary = uniq(group.map(b => b.summary)).join(' · ').slice(0, 120)
+ const note = uniq(group.map(b => b.note ?? '')).join(' · ').slice(0, 80)
+
+ const merged: ClassifiedBlock = {
+ ...dominant,
+ blockName: group.length > 1 ? `${dominant.blockName} (+${group.length - 1})` : dominant.blockName,
+ summary,
+ note,
+ hours: group.reduce((s, b) => s + b.hours, 0),
+ visitCount: group.reduce((s, b) => s + b.visitCount, 0),
+ firstVisitTime: sorted[0]!.firstVisitTime,
+ lastVisitTime: sorted[sorted.length - 1]!.lastVisitTime,
+ startTime: sorted[0]!.startTime,
+ endTime: sorted[sorted.length - 1]!.endTime,
+ confidence: Math.max(...group.map(b => b.confidence)) as ClassifiedBlock['confidence'],
+ titles,
+ urls,
+ rawTitles,
+ rawUrls,
+ }
+ if (commits.length > 0) merged.commits = commits
+ if (linearIssues.length > 0) merged.linearIssues = linearIssues
+ return merged
+}
diff --git a/src/domain/usecases/groupLinearIssuesIntoBlocks.test.ts b/src/domain/usecases/groupLinearIssuesIntoBlocks.test.ts
new file mode 100644
index 0000000..be7a628
--- /dev/null
+++ b/src/domain/usecases/groupLinearIssuesIntoBlocks.test.ts
@@ -0,0 +1,53 @@
+import { describe, it, expect } from 'vitest'
+import { groupLinearIssuesIntoBlocks } from './groupLinearIssuesIntoBlocks'
+import type { LinearIssue } from '../entities/LinearIssue'
+import type { GitHubCommit } from '../entities/GitHubCommit'
+
+const issue = (identifier: string, completedAt = '2026-06-01T15:00:00Z', title = 'Some work'): LinearIssue => ({
+ identifier,
+ title,
+ completedAt,
+ url: `https://linear.app/x/issue/${identifier}`,
+})
+
+const commit = (message: string): GitHubCommit => ({
+ sha: 'abc',
+ message,
+ repo: 'org/repo',
+ branch: 'main',
+ timestamp: '2026-06-01T10:00:00Z',
+ time: '10:00',
+ date: '2026-06-01',
+})
+
+describe('groupLinearIssuesIntoBlocks', () => {
+ it('returns nothing when there are no issues', () => {
+ expect(groupLinearIssuesIntoBlocks([], [], '2026-06-01')).toEqual([])
+ })
+
+ it('makes a block for an issue with no commit footprint', () => {
+ const blocks = groupLinearIssuesIntoBlocks([issue('ENG-42')], [], '2026-06-01')
+ expect(blocks).toHaveLength(1)
+ expect(blocks[0]!.urlPattern).toBe('linear:ENG-42')
+ expect(blocks[0]!.titles[0]).toContain('ENG-42')
+ expect(blocks[0]!.hours).toBe(0.5)
+ })
+
+ it('skips an issue already referenced by a commit (covered by a higher source)', () => {
+ const blocks = groupLinearIssuesIntoBlocks(
+ [issue('ENG-42'), issue('ENG-99')],
+ [commit('fix: handle edge case ENG-42')],
+ '2026-06-01',
+ )
+ expect(blocks.map(b => b.urlPattern)).toEqual(['linear:ENG-99'])
+ })
+
+ it('ignores issues completed on another day', () => {
+ const blocks = groupLinearIssuesIntoBlocks(
+ [issue('ENG-1', '2026-05-30T12:00:00Z')],
+ [],
+ '2026-06-01',
+ )
+ expect(blocks).toEqual([])
+ })
+})
diff --git a/src/domain/usecases/groupLinearIssuesIntoBlocks.ts b/src/domain/usecases/groupLinearIssuesIntoBlocks.ts
new file mode 100644
index 0000000..2ad6de7
--- /dev/null
+++ b/src/domain/usecases/groupLinearIssuesIntoBlocks.ts
@@ -0,0 +1,45 @@
+import type { LinearIssue } from '../entities/LinearIssue'
+import type { GitHubCommit } from '../entities/GitHubCommit'
+import type { HistoryBlock } from '../entities/HistoryBlock'
+
+/**
+ * Turns completed Linear issues into their own classifiable blocks — but only
+ * the ones a higher-priority source (commits) doesn't already explain.
+ *
+ * Linear is the 4th source in the priority order (calendar > commits > browser >
+ * Linear > trends, see CONTEXT.md). An issue whose identifier appears in a
+ * commit message that day is considered covered by that commit (the commit block
+ * absorbs it via relatedIssueIds) and gets no block of its own. The rest — e.g. a
+ * research or design ticket with no code footprint — become standalone blocks so
+ * the day reflects that work too.
+ *
+ * A completed issue carries no duration, so each block is seeded minimally; the
+ * packer grows it toward its project's historical size when filling the day.
+ */
+const SEED_HOURS = 0.5
+const SEED_TIME = '09:00'
+
+export function groupLinearIssuesIntoBlocks(
+ issues: LinearIssue[],
+ commits: GitHubCommit[],
+ date: string,
+): HistoryBlock[] {
+ const forDate = issues.filter(i => i.completedAt.slice(0, 10) === date)
+ if (forDate.length === 0) return []
+
+ const commitText = commits.map(c => c.message).join(' ')
+ const coveredByCommit = (identifier: string): boolean => commitText.includes(identifier)
+
+ return forDate
+ .filter(issue => !coveredByCommit(issue.identifier))
+ .map(issue => ({
+ date,
+ urlPattern: `linear:${issue.identifier}`,
+ urls: [issue.url],
+ titles: [`${issue.identifier} · ${issue.title}`],
+ visitCount: 1,
+ firstVisitTime: SEED_TIME,
+ lastVisitTime: SEED_TIME,
+ hours: SEED_HOURS,
+ }))
+}
diff --git a/src/domain/usecases/mappingCacheKey.test.ts b/src/domain/usecases/mappingCacheKey.test.ts
new file mode 100644
index 0000000..97f407f
--- /dev/null
+++ b/src/domain/usecases/mappingCacheKey.test.ts
@@ -0,0 +1,24 @@
+import { describe, it, expect } from 'vitest'
+import { mappingCacheKey } from './mappingCacheKey'
+
+describe('mappingCacheKey', () => {
+ it('strips the @HH:mm session suffix from github commit patterns', () => {
+ expect(mappingCacheKey('github.com/acme/web@09:15')).toBe('github.com/acme/web')
+ expect(mappingCacheKey('github.com/acme/web@14:00')).toBe('github.com/acme/web')
+ })
+
+ it('maps two sessions of the same repo to the same key', () => {
+ expect(mappingCacheKey('github.com/acme/api@08:30')).toBe(
+ mappingCacheKey('github.com/acme/api@16:45'),
+ )
+ })
+
+ it('leaves a github pattern without a time suffix untouched', () => {
+ expect(mappingCacheKey('github.com/acme/web')).toBe('github.com/acme/web')
+ })
+
+ it('leaves non-github url patterns untouched', () => {
+ expect(mappingCacheKey('app.linear.team/issue')).toBe('app.linear.team/issue')
+ expect(mappingCacheKey('docs.google.com/document')).toBe('docs.google.com/document')
+ })
+})
diff --git a/src/domain/usecases/mappingCacheKey.ts b/src/domain/usecases/mappingCacheKey.ts
new file mode 100644
index 0000000..7534ef5
--- /dev/null
+++ b/src/domain/usecases/mappingCacheKey.ts
@@ -0,0 +1,18 @@
+/**
+ * Canonical key under which a block's project/service mapping is cached.
+ *
+ * Most blocks key on their `urlPattern` directly. Commit blocks are the
+ * exception: their pattern is `github.com/owner/repo@HH:mm` (the start time is
+ * baked in so multiple sessions of one repo stay distinct in the history
+ * store). Keying the *mapping* on that time-specific pattern means a repo's
+ * learned project never gets reused — every day's session has a new time and
+ * misses the cache. Strip the `@HH:mm` suffix so the repo→project mapping is
+ * stable across days.
+ */
+export function mappingCacheKey(urlPattern: string): string {
+ if (urlPattern.startsWith('github.com/')) {
+ const at = urlPattern.indexOf('@')
+ return at === -1 ? urlPattern : urlPattern.slice(0, at)
+ }
+ return urlPattern
+}
diff --git a/src/infrastructure/googlecalendar/GoogleCalendarRepository.test.ts b/src/infrastructure/googlecalendar/GoogleCalendarRepository.test.ts
index 9def049..7e64bd8 100644
--- a/src/infrastructure/googlecalendar/GoogleCalendarRepository.test.ts
+++ b/src/infrastructure/googlecalendar/GoogleCalendarRepository.test.ts
@@ -142,6 +142,36 @@ describe('GoogleCalendarRepository', () => {
expect(e2.attendees).toEqual(['other@x.com'])
})
+ it("keeps un-RSVP'd (needsAction) meetings and drops only declined ones", async () => {
+ const kc = makeKeychain({ 'google-access-token': 'tok', 'google-token-expiry': FUTURE })
+ fetchMock.mockResolvedValueOnce({
+ ok: true,
+ json: async () => ({
+ items: [
+ // never responded — a recurring team meeting you attend without RSVP
+ {
+ id: 'na',
+ summary: 'Standup T3',
+ start: { dateTime: '2026-05-20T09:00:00Z' },
+ end: { dateTime: '2026-05-20T09:15:00Z' },
+ attendees: [{ email: 'me@x.com', self: true, responseStatus: 'needsAction' }],
+ },
+ // explicitly declined — still excluded
+ {
+ id: 'dec',
+ summary: 'Optional sync',
+ start: { dateTime: '2026-05-20T12:00:00Z' },
+ end: { dateTime: '2026-05-20T13:00:00Z' },
+ attendees: [{ email: 'me@x.com', self: true, responseStatus: 'declined' }],
+ },
+ ],
+ }),
+ })
+ const repo = new GoogleCalendarRepository(kc, 'cid', 'secret')
+ const events = await repo.fetchEvents(start, end)
+ expect(events.map(e => e.id)).toEqual(['na'])
+ })
+
it('returns [] when items is absent', async () => {
const kc = makeKeychain({ 'google-access-token': 'tok', 'google-token-expiry': FUTURE })
fetchMock.mockResolvedValueOnce({ ok: true, json: async () => ({}) })
diff --git a/src/infrastructure/googlecalendar/GoogleCalendarRepository.ts b/src/infrastructure/googlecalendar/GoogleCalendarRepository.ts
index a043bbf..8cb6699 100644
--- a/src/infrastructure/googlecalendar/GoogleCalendarRepository.ts
+++ b/src/infrastructure/googlecalendar/GoogleCalendarRepository.ts
@@ -106,7 +106,10 @@ export class GoogleCalendarRepository implements IGoogleCalendarRepository {
if (!ev.attendees) return true // solo events have no attendees array
const self = ev.attendees.find(a => a.self)
if (!self) return true
- return self.responseStatus === 'accepted' || self.responseStatus === 'tentative'
+ // Keep everything except meetings you explicitly declined. Recurring team
+ // meetings are often left on 'needsAction' (never RSVP'd) yet still attended,
+ // and calendar is the highest-priority source — so only 'declined' drops out.
+ return self.responseStatus !== 'declined'
}
private toCalendarEvent(ev: GoogleEvent): CalendarEvent | null {
diff --git a/src/ui/components/DayTimeline.helpers.test.ts b/src/ui/components/DayTimeline.helpers.test.ts
index 1c73969..3bd757c 100644
--- a/src/ui/components/DayTimeline.helpers.test.ts
+++ b/src/ui/components/DayTimeline.helpers.test.ts
@@ -332,4 +332,20 @@ describe('assignBlockColumns', () => {
const shorter = columns.find(c => c.block.endTime === '09:30')!
expect(shorter.col).toBe(0)
})
+
+ it('scopes the width split to the overlap cluster — non-overlapping blocks stay full width', () => {
+ // A is alone; B and C overlap each other. A must keep cols=1 (full width),
+ // only B and C share a band (cols=2). This is the GCal fix: one local overlap
+ // no longer narrows the whole day.
+ const { columns, numCols } = assignBlockColumns([
+ b('09:00', '10:00'), // A — no overlap
+ b('10:00', '10:30'), // B ─┐ overlap
+ b('10:00', '11:00'), // C ─┘
+ ])
+ const byEnd = Object.fromEntries(columns.map(c => [c.block.endTime, c]))
+ expect(byEnd['10:00']!.cols).toBe(1) // A: full width
+ expect(byEnd['10:30']!.cols).toBe(2) // B: half of its band
+ expect(byEnd['11:00']!.cols).toBe(2) // C: half of its band
+ expect(numCols).toBe(2)
+ })
})
diff --git a/src/ui/components/DayTimeline.helpers.ts b/src/ui/components/DayTimeline.helpers.ts
index e7a2789..25a3cd2 100644
--- a/src/ui/components/DayTimeline.helpers.ts
+++ b/src/ui/components/DayTimeline.helpers.ts
@@ -80,16 +80,20 @@ export function computeTimelineBlocks(
}
/**
- * Assigns each block to a column via interval partitioning: blocks that don't
- * overlap in time share a column, so a gap-free day renders in a SINGLE column.
- * A second column only appears where two blocks genuinely overlap.
+ * Lays blocks out Google-Calendar style: blocks are grouped into transitive
+ * overlap CLUSTERS (a chain of mutually-overlapping blocks), and the width split
+ * happens only WITHIN a cluster. A block that overlaps nothing keeps full width;
+ * two concurrent blocks each take half of just their shared band — no block is
+ * ever flung to a far global column.
*
- * Process in start-time order (shorter first on ties) and place each block in
- * the first column whose previous block has already ended.
+ * Each returned entry carries `col` (its column index inside its cluster) and
+ * `cols` (the number of columns in that cluster, i.e. its local width divisor).
+ * `numCols` is the max `cols` across all clusters, kept for callers that want a
+ * global sense of how crowded the day is.
*/
export function assignBlockColumns(
blocks: T[],
-): { columns: { block: T; col: number }[]; numCols: number } {
+): { columns: { block: T; col: number; cols: number }[]; numCols: number } {
const sorted = [...blocks].sort((a, b) => {
const startA = timeToMinutes(a.startTime)
const startB = timeToMinutes(b.startTime)
@@ -99,19 +103,49 @@ export function assignBlockColumns {
+ const columns: { block: T; col: number; cols: number }[] = []
+ let numCols = 1
+
+ // Sweep start-time-ordered blocks into clusters: a block joins the current
+ // cluster if it starts before the cluster's running end.
+ let cluster: T[] = []
+ let clusterEnd = -1
+ const flushCluster = () => {
+ if (cluster.length === 0) return
+ // Greedy column assignment within the cluster: first column whose previous
+ // block has already ended.
+ const columnEndMin: number[] = []
+ const placed = cluster.map(block => {
+ const startMin = timeToMinutes(block.startTime)
+ let col = columnEndMin.findIndex(endMin => startMin >= endMin)
+ if (col === -1) {
+ col = columnEndMin.length
+ columnEndMin.push(0)
+ }
+ columnEndMin[col] = timeToMinutes(block.endTime)
+ return { block, col }
+ })
+ const cols = columnEndMin.length
+ numCols = Math.max(numCols, cols)
+ for (const p of placed) columns.push({ block: p.block, col: p.col, cols })
+ cluster = []
+ clusterEnd = -1
+ }
+
+ for (const block of sorted) {
const startMin = timeToMinutes(block.startTime)
- let col = columnEndMin.findIndex(endMin => startMin >= endMin)
- if (col === -1) {
- col = columnEndMin.length
- columnEndMin.push(0)
+ if (cluster.length > 0 && startMin < clusterEnd) {
+ cluster.push(block)
+ clusterEnd = Math.max(clusterEnd, timeToMinutes(block.endTime))
+ } else {
+ flushCluster()
+ cluster = [block]
+ clusterEnd = timeToMinutes(block.endTime)
}
- columnEndMin[col] = timeToMinutes(block.endTime)
- return { block, col }
- })
+ }
+ flushCluster()
- return { columns, numCols: Math.max(1, columnEndMin.length) }
+ return { columns, numCols }
}
export type TimelineRow = {
diff --git a/src/ui/components/DayTimeline.tsx b/src/ui/components/DayTimeline.tsx
index 23fad43..a1a057d 100644
--- a/src/ui/components/DayTimeline.tsx
+++ b/src/ui/components/DayTimeline.tsx
@@ -168,6 +168,10 @@ export function DayTimeline({
function renderBlock(block: TimelineBlock, height: number, key: string | number, positionStyle?: React.CSSProperties) {
const baseStyle: React.CSSProperties = { height, ...positionStyle }
+ // Short blocks (e.g. 15-min meetings) can't fit two stacked lines; render a
+ // single compact line (title + time inline) so nothing clips.
+ const compact = height < 42
+
if (block.type === 'entry') {
return (
)
@@ -221,22 +238,37 @@ export function DayTimeline({
display: 'flex',
flexDirection: 'column',
justifyContent: 'center',
- padding: '4px 12px',
+ padding: compact ? '2px 10px' : '4px 12px',
textAlign: 'left',
}}
>
-
- {badgeLabel}
-
-
{(() => {
- // Interval partitioning: non-overlapping blocks share a column, so a
- // gap-free day renders in a single column (see assignBlockColumns).
+ // Google-Calendar-style layout: blocks are grouped into transitive
+ // overlap clusters and the width split happens only within a cluster,
+ // so a non-overlapping block stays full width and two concurrent blocks
+ // each take half of just their shared band (see assignBlockColumns).
const contentBlocks = flatBlocks.filter(b => b.type === 'entry' || b.type === 'concept')
- const { columns: blockColumns, numCols } = assignBlockColumns(contentBlocks)
- const colWidthPct = 100 / numCols
+ const { columns: blockColumns } = assignBlockColumns(contentBlocks)
- // Render gap blocks first (always column 0, full width only if no content blocks)
+ // Render gap blocks first (full width — they never overlap content)
const gapElements = flatBlocks
.filter(b => b.type === 'gap' && b.suggestion)
/* v8 ignore start -- gaps from mergeConceptsIntoTimeline never carry a suggestion, and computeTimelineBlocks (which does attach suggestions) only runs when the timeline is hidden by showEmptyHint */
@@ -530,12 +563,10 @@ export function DayTimeline({
if (block.type !== 'gap') return null
const top = blockTop(block.startTime)
const height = blockPx(block.startTime, block.endTime)
- // Gaps fill column 0 width (or full width if single column)
- const width = `${colWidthPct}%`
return (