collavre_plan 0.1.0 → 0.1.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: f1f4d20acde286797f3e083741480a5c654a0c93f9a674c09cedfc5bc7865f55
4
- data.tar.gz: 81034f4bd8a3e0c6b36bd8d59786fbae8f5f0e141fcf34c29d88fce8feadf199
3
+ metadata.gz: e99d6a30bd4978b465c1bb84e5733c85884f40c9e824178a0157bfa31779c81b
4
+ data.tar.gz: 0c118b9a2083ecbd3edb475c8a1bfa255c7df380579cf91848c16f15b338b4ac
5
5
  SHA512:
6
- metadata.gz: 6a366c4ef91d21b2caba0215f42ac7a1489b9b5ed251039da6baf12858b7f0de7e0cca533a3783d7d21f3839aa642811dc86e62a75522333f882a3bd1d30652f
7
- data.tar.gz: e75dd6df51d244be43629f6ac461493810c71d61e6b4db42d8261d1dd574c71deb1b50b328cfc8ce5015c4ac2a10c0d7e55dde085f880d6a29cfb37e9ab1c9af
6
+ metadata.gz: 2549c3dac73f60dcf4ede3c9dc605e9ed20153b72195e8f41721eae74850d5d2b3d1ece7d28baf8ffeca69f99f744c5ae5ec4561a9250c2fbc0e47fad34a188f
7
+ data.tar.gz: 81560db00fbd1ccfa4b31f2be018a970fbbab935f9c36b06e8b2308fef24792913284fc61030ab4b0b3516607af463811bbd1d5112b27879d6e8aa83245f4ced
@@ -0,0 +1,183 @@
1
+ /*
2
+ * Plans timeline panel styles.
3
+ *
4
+ * Owned by the collavre_plan engine (not the host application.css) so the
5
+ * engine ships its own CSS. Loaded via stylesheet_link_tag in the engine's
6
+ * navigation panel partial. Design tokens (--color-*, --surface-*, --ease-*)
7
+ * are provided globally by collavre/design_tokens.css.
8
+ */
9
+
10
+ #plans-list-area {
11
+ max-width: var(--max-width);
12
+ width: min(100%, var(--max-width));
13
+ margin: 0.5em auto;
14
+ box-sizing: border-box;
15
+ }
16
+
17
+ #plans-list-area hr {
18
+ border: 0;
19
+ border-top: 1px solid var(--color-border);
20
+ }
21
+
22
+ .horizontal-timeline {
23
+ overflow-x: auto;
24
+ position: relative;
25
+ padding-bottom: 1em;
26
+ width: 100%;
27
+ }
28
+
29
+ .plans-timeline-wrapper {
30
+ position: relative;
31
+ width: 100%;
32
+ }
33
+
34
+ .plans-timeline-wrapper #timeline-today-btn {
35
+ position: absolute;
36
+ top: 0;
37
+ right: 0;
38
+ z-index: 1;
39
+ }
40
+
41
+ .horizontal-timeline .timeline-scroll {
42
+ position: relative;
43
+ white-space: nowrap;
44
+ height: 120px;
45
+ }
46
+
47
+ .horizontal-timeline .timeline-day {
48
+ display: inline-block;
49
+ width: 80px;
50
+ box-sizing: border-box;
51
+ border-right: 1px solid var(--color-border);
52
+ text-align: center;
53
+ font-size: 0.8em;
54
+ }
55
+
56
+ .plan-form-container {
57
+ padding: 0.5em;
58
+ display: flex;
59
+ justify-content: flex-end;
60
+ }
61
+
62
+ .plan-form-container form {
63
+ display: flex;
64
+ flex-direction: column;
65
+ gap: var(--paragraph-space-1);
66
+ max-width: min(28rem, 100%);
67
+ }
68
+
69
+ .plan-bar {
70
+ position: absolute;
71
+ height: 20px;
72
+ background: var(--color-border);
73
+ border-radius: var(--radius-2);
74
+ overflow: hidden;
75
+ cursor: pointer;
76
+ }
77
+
78
+ .plan-bar .plan-progress {
79
+ background: var(--color-complete);
80
+ height: 100%;
81
+ }
82
+
83
+ .plan-bar .plan-label {
84
+ position: absolute;
85
+ left: 2px;
86
+ top: 2px;
87
+ font-size: 0.75em;
88
+ color: var(--color-text);
89
+ }
90
+
91
+ .plan-bar .delete-plan-btn {
92
+ position: absolute;
93
+ top: 2px;
94
+ right: 2px;
95
+ border: none;
96
+ background: none;
97
+ font-weight: bold;
98
+ cursor: pointer;
99
+ color: var(--color-bg);
100
+ }
101
+
102
+ /* Registration markers: creatives drawn at their created_at (single day). */
103
+ .plan-bar--registration {
104
+ background: var(--color-link);
105
+ opacity: 0.85;
106
+ min-width: 6px;
107
+ }
108
+
109
+ .plan-bar--registration .plan-progress {
110
+ background: transparent;
111
+ }
112
+
113
+ /* Modification markers: creatives drawn at their updated_at (single day).
114
+ Amber to read as distinct from the blue registration markers. */
115
+ .plan-bar--modification {
116
+ background: var(--color-warning);
117
+ opacity: 0.85;
118
+ min-width: 6px;
119
+ }
120
+
121
+ .plan-bar--modification .plan-progress {
122
+ background: transparent;
123
+ }
124
+
125
+ /* Timeline chip filters (Registered, ...) — default-off toggles. */
126
+ .timeline-controls {
127
+ display: flex;
128
+ align-items: center;
129
+ gap: 0.5em;
130
+ margin-bottom: 0.25em;
131
+ }
132
+
133
+ .timeline-chips {
134
+ display: flex;
135
+ gap: 0.25em;
136
+ }
137
+
138
+ .timeline-chip {
139
+ /* Inactive chips sit on a neutral surface (not transparent) so the off-state
140
+ still reads as a toggle against the timeline background, without looking
141
+ active. */
142
+ border: 1px solid var(--color-border);
143
+ background: var(--surface-secondary);
144
+ color: var(--color-text);
145
+ border-radius: var(--radius-round);
146
+ padding: 0.1em 0.7em;
147
+ font-size: 0.75em;
148
+ line-height: 1.6;
149
+ cursor: pointer;
150
+ transition: background-color 0.15s var(--ease-2), color 0.15s var(--ease-2), border-color 0.15s var(--ease-2);
151
+ }
152
+
153
+ .timeline-chip:hover {
154
+ background: var(--surface-hover);
155
+ color: var(--color-text);
156
+ }
157
+
158
+ .timeline-chip:focus-visible {
159
+ outline: 2px solid var(--color-link);
160
+ outline-offset: 1px;
161
+ }
162
+
163
+ .timeline-chip--active {
164
+ background: var(--color-link);
165
+ border-color: var(--color-link);
166
+ color: var(--color-bg);
167
+ }
168
+
169
+ /* Keep the active chip filled on hover — don't fall back to the inactive surface. */
170
+ .timeline-chip--active:hover {
171
+ background: var(--color-link);
172
+ color: var(--color-bg);
173
+ }
174
+
175
+ /* The Modified chip's active fill matches its amber timeline markers
176
+ (.plan-bar--modification) so the filter color maps to the dots it controls.
177
+ --text-on-badge is the codebase convention for text over --color-warning. */
178
+ #chip-modifications.timeline-chip--active,
179
+ #chip-modifications.timeline-chip--active:hover {
180
+ background: var(--color-warning);
181
+ border-color: var(--color-warning);
182
+ color: var(--text-on-badge);
183
+ }
@@ -1,6 +1,12 @@
1
1
  <div class="plans-timeline-wrapper">
2
- <button id="timeline-today-btn" class="btn btn-xs" type="button"><%= t('collavre.plans.today', default: '오늘') %></button>
3
- <div id="plans-timeline" class="horizontal-timeline" data-plans='<%= raw plan_data.to_json %>' data-start-date="<%= @start_date %>" data-end-date="<%= @end_date %>" data-delete-confirm="<%= t('collavre.plans.delete_confirm', default: 'Are you sure?') %>"></div>
2
+ <div class="timeline-controls">
3
+ <button id="timeline-today-btn" class="btn btn-xs" type="button"><%= t('collavre.plans.today', default: '오늘') %></button>
4
+ <div class="timeline-chips">
5
+ <button type="button" id="chip-registrations" class="timeline-chip<%= ' timeline-chip--active' if show_registrations %>" data-chip="registrations" aria-pressed="<%= show_registrations %>"><%= t('collavre.plans.chip_registrations', default: 'Registered') %></button>
6
+ <button type="button" id="chip-modifications" class="timeline-chip<%= ' timeline-chip--active' if show_modifications %>" data-chip="modifications" aria-pressed="<%= show_modifications %>"><%= t('collavre.plans.chip_modifications', default: 'Modified') %></button>
7
+ </div>
8
+ </div>
9
+ <div id="plans-timeline" class="horizontal-timeline" data-plans='<%= raw plan_data.to_json %>' data-start-date="<%= @start_date %>" data-end-date="<%= @end_date %>" data-registrations="<%= show_registrations %>" data-modifications="<%= show_modifications %>" data-delete-confirm="<%= t('collavre.plans.delete_confirm', default: 'Are you sure?') %>"></div>
4
10
  </div>
5
11
  <hr>
6
12
  <div class="plan-form-container">
@@ -1,18 +1,25 @@
1
1
  module Collavre
2
2
  class PlansTimelineComponent < ViewComponent::Base
3
- # Accepts pre-filtered plans and calendar_events from the controller
4
- def initialize(plans:, calendar_events: Collavre::CalendarEvent.none)
3
+ # Accepts pre-filtered plans, calendar_events, registrations and modifications from the controller
4
+ def initialize(plans:, calendar_events: Collavre::CalendarEvent.none, registrations: [], show_registrations: false, modifications: [], show_modifications: false)
5
5
  @start_date = Date.current - 30
6
6
  @end_date = Date.current + 30
7
7
  @plans = plans
8
8
  @calendar_events = calendar_events
9
+ @registrations = registrations
10
+ @show_registrations = show_registrations
11
+ @modifications = modifications
12
+ @show_modifications = show_modifications
9
13
  end
10
14
 
11
- attr_reader :plans, :calendar_events, :start_date, :end_date
15
+ attr_reader :plans, :calendar_events, :registrations, :start_date, :end_date, :show_registrations, :modifications, :show_modifications
12
16
 
13
17
  # Called after component enters render context - safe to use helpers here
14
18
  def plan_data
15
- @plan_data ||= @plans.map { |plan| plan_item(plan) } + @calendar_events.map { |event| calendar_item(event) }
19
+ @plan_data ||= @plans.map { |plan| plan_item(plan) } +
20
+ @calendar_events.map { |event| calendar_item(event) } +
21
+ @registrations.map { |creative| registration_item(creative) } +
22
+ @modifications.map { |creative| modification_item(creative) }
16
23
  end
17
24
 
18
25
  private
@@ -42,6 +49,32 @@ class PlansTimelineComponent < ViewComponent::Base
42
49
  }
43
50
  end
44
51
 
52
+ def registration_item(creative)
53
+ {
54
+ id: "registration_#{creative.id}",
55
+ type: "registration",
56
+ name: (creative.effective_description(nil, false).presence || I18n.t("collavre.plans.registration_fallback", id: creative.id)),
57
+ created_at: creative.created_at.to_date,
58
+ target_date: creative.created_at.to_date,
59
+ progress: creative.progress,
60
+ path: helpers.collavre.creative_path(creative),
61
+ deletable: false
62
+ }
63
+ end
64
+
65
+ def modification_item(creative)
66
+ {
67
+ id: "modification_#{creative.id}",
68
+ type: "modification",
69
+ name: (creative.effective_description(nil, false).presence || I18n.t("collavre.plans.modification_fallback", id: creative.id)),
70
+ created_at: creative.updated_at.to_date,
71
+ target_date: creative.updated_at.to_date,
72
+ progress: creative.progress,
73
+ path: helpers.collavre.creative_path(creative),
74
+ deletable: false
75
+ }
76
+ end
77
+
45
78
  def plan_creatives_path(plan)
46
79
  if helpers.params[:id].present?
47
80
  helpers.collavre.creative_path(helpers.params[:id], tags: [ plan.id ])
@@ -1,5 +1,11 @@
1
1
  module CollavrePlan
2
2
  class PlansController < ApplicationController
3
+ # Cap how many registration/modification markers we draw so a busy window
4
+ # never floods the timeline (or the JSON payload). Most recent within the
5
+ # window win.
6
+ REGISTRATION_LIMIT = 300
7
+ MODIFICATION_LIMIT = 300
8
+
3
9
  def index
4
10
  center = if params[:date].present?
5
11
  Date.parse(params[:date]) rescue Date.current
@@ -20,14 +26,24 @@ module CollavrePlan
20
26
  shared_events = events_in_scope.reject { |event| event.user_id == Current.user.id }
21
27
  .select { |event| event.creative&.has_permission?(Current.user, :write) }
22
28
  @calendar_events = (own_events + shared_events).uniq.sort_by(&:start_time)
29
+
30
+ # "Registered"/"Modified" chips are default-off, so their creatives are
31
+ # only gathered when the client explicitly asks (lazy, zero cost normally).
32
+ @show_registrations = ActiveModel::Type::Boolean.new.cast(params[:registrations]) == true
33
+ @registrations = @show_registrations ? registration_creatives(start_date, end_date) : []
34
+ @show_modifications = ActiveModel::Type::Boolean.new.cast(params[:modifications]) == true
35
+ @modifications = @show_modifications ? modification_creatives(start_date, end_date) : []
36
+
23
37
  respond_to do |format|
24
38
  format.html do
25
- render html: render_to_string(Collavre::PlansTimelineComponent.new(plans: @plans, calendar_events: @calendar_events), layout: false)
39
+ render html: render_to_string(Collavre::PlansTimelineComponent.new(plans: @plans, calendar_events: @calendar_events, registrations: @registrations, show_registrations: @show_registrations, modifications: @modifications, show_modifications: @show_modifications), layout: false)
26
40
  end
27
41
  format.json do
28
42
  plan_jsons = @plans.map { |p| plan_json(p) }
29
43
  event_jsons = @calendar_events.map { |e| calendar_json(e) }
30
- render json: plan_jsons + event_jsons
44
+ registration_jsons = @registrations.map { |c| registration_json(c) }
45
+ modification_jsons = @modifications.map { |c| modification_json(c) }
46
+ render json: plan_jsons + event_jsons + registration_jsons + modification_jsons
31
47
  end
32
48
  end
33
49
  end
@@ -58,8 +74,8 @@ module CollavrePlan
58
74
  end
59
75
 
60
76
  def destroy
61
- @plan = Collavre::Plan.find(params[:id])
62
- return render_forbidden unless plan_editable_by_current_user?
77
+ @plan = Collavre::Plan.find_by(id: params[:id])
78
+ raise ActiveRecord::RecordNotFound unless @plan && plan_editable_by_current_user?
63
79
 
64
80
  @plan.destroy
65
81
  respond_to do |format|
@@ -72,8 +88,8 @@ module CollavrePlan
72
88
  end
73
89
 
74
90
  def update
75
- @plan = Collavre::Plan.find(params[:id])
76
- return render_forbidden unless plan_editable_by_current_user?
91
+ @plan = Collavre::Plan.find_by(id: params[:id])
92
+ raise ActiveRecord::RecordNotFound unless @plan && plan_editable_by_current_user?
77
93
 
78
94
  if @plan.update(plan_update_params)
79
95
  respond_to do |format|
@@ -119,16 +135,85 @@ module CollavrePlan
119
135
  tagged_creative.has_permission?(Current.user, :write)
120
136
  end
121
137
 
122
- def render_forbidden
123
- respond_to do |format|
124
- format.html do
125
- redirect_back fallback_location: main_app.root_path,
126
- alert: t("collavre.plans.update_forbidden", default: "You do not have permission to update this plan.")
127
- end
128
- format.json do
129
- render json: { error: "forbidden" }, status: :forbidden
130
- end
131
- end
138
+ # Registered creatives owned by the current user, drawn at their created_at.
139
+ # Owner-scoped (cheap, indexed) and capped — readable-but-shared creatives are
140
+ # intentionally a follow-up to avoid a per-creative permission fan-out here.
141
+ def registration_creatives(start_date, end_date)
142
+ # Range-compare in the user's zone (set_time_zone wraps the request in
143
+ # Time.use_zone) so day-edge creatives match the local created_at.to_date
144
+ # we render the marker at; also stays sargable (no DATE() cast on the column).
145
+ Collavre::Creative.active
146
+ .where(user_id: Current.user.id)
147
+ .where(created_at: start_date.beginning_of_day..end_date.end_of_day)
148
+ .where.not(id: plan_anchor_creative_ids)
149
+ .order(created_at: :desc)
150
+ .limit(REGISTRATION_LIMIT)
151
+ .to_a
152
+ end
153
+
154
+ # Plan#start_date= overwrites the anchor creative's created_at (via
155
+ # update_column), repurposing it as the plan start date — so these creatives
156
+ # already render as plan bars at that date and their created_at is not a true
157
+ # registration. Exclude them from the Registered chip to avoid a duplicate,
158
+ # mislabeled marker. (The Modified chip handles anchors via the immutable
159
+ # plan-label created_at instead; see modification_creatives.)
160
+ def plan_anchor_creative_ids
161
+ Collavre::Plan.where.not(creative_id: nil).select(:creative_id)
162
+ end
163
+
164
+ def registration_json(creative)
165
+ {
166
+ id: "registration_#{creative.id}",
167
+ type: "registration",
168
+ name: (creative.effective_description(nil, false).presence || I18n.t("collavre.plans.registration_fallback", id: creative.id)),
169
+ created_at: creative.created_at.to_date,
170
+ target_date: creative.created_at.to_date,
171
+ progress: creative.progress,
172
+ path: Collavre::Engine.routes.url_helpers.creative_path(creative),
173
+ deletable: false
174
+ }
175
+ end
176
+
177
+ # Modified creatives owned by the current user, drawn at their updated_at.
178
+ # We only include creatives genuinely edited after they came into being, so
179
+ # the "Modified" chip stays distinct from "Registered" rather than duplicating
180
+ # it. Owner-scoped and capped, mirroring registration_creatives.
181
+ #
182
+ # Plan#start_date= rewrites an anchor creative's created_at to the chosen plan
183
+ # start date (via update_column, leaving updated_at untouched), so for anchors
184
+ # created_at is the plan start, not a creation time, and updated_at > created_at
185
+ # can't signal a real edit (it's true for almost every anchor). The plan
186
+ # label's own created_at is immutable, so we measure "edited since setup"
187
+ # against the earliest plan's created_at for anchors; COALESCE falls back to
188
+ # created_at for ordinary creatives. This surfaces genuine post-setup edits to
189
+ # planned creatives while still suppressing the setup-only false positive.
190
+ def modification_creatives(start_date, end_date)
191
+ Collavre::Creative.active
192
+ .where(user_id: Current.user.id)
193
+ .where(updated_at: start_date.beginning_of_day..end_date.end_of_day)
194
+ .where(
195
+ "creatives.updated_at > COALESCE(" \
196
+ "(SELECT MIN(plan_labels.created_at) FROM labels plan_labels " \
197
+ "WHERE plan_labels.creative_id = creatives.id " \
198
+ "AND plan_labels.type = ?), creatives.created_at)",
199
+ Collavre::Plan.sti_name
200
+ )
201
+ .order(updated_at: :desc)
202
+ .limit(MODIFICATION_LIMIT)
203
+ .to_a
204
+ end
205
+
206
+ def modification_json(creative)
207
+ {
208
+ id: "modification_#{creative.id}",
209
+ type: "modification",
210
+ name: (creative.effective_description(nil, false).presence || I18n.t("collavre.plans.modification_fallback", id: creative.id)),
211
+ created_at: creative.updated_at.to_date,
212
+ target_date: creative.updated_at.to_date,
213
+ progress: creative.progress,
214
+ path: Collavre::Engine.routes.url_helpers.creative_path(creative),
215
+ deletable: false
216
+ }
132
217
  end
133
218
 
134
219
  def plan_json(plan, creative_id: nil)
@@ -1,4 +1,5 @@
1
1
  import { Controller } from '@hotwired/stimulus'
2
+ import { alertDialog } from 'collavre/lib/utils/dialog'
2
3
 
3
4
  // Self-contained controller: attaches to the modal wrapper element
4
5
  // and listens for 'plan:open-modal' on document for cross-element communication.
@@ -119,6 +120,6 @@ export default class extends Controller {
119
120
 
120
121
  alert(message) {
121
122
  if (!message) return
122
- window.alert(message)
123
+ alertDialog(message)
123
124
  }
124
125
  }
@@ -1,3 +1,5 @@
1
+ import { alertDialog, confirmDialog } from "collavre/lib/utils/dialog";
2
+
1
3
  let plansTimelineScriptInitialized = false;
2
4
 
3
5
  if (!plansTimelineScriptInitialized) {
@@ -24,6 +26,12 @@ if (!plansTimelineScriptInitialized) {
24
26
  var endDate = new Date(container.dataset.endDate || new Date());
25
27
  container.dataset.lastLoadedDate = new Date().toISOString().slice(0, 10);
26
28
 
29
+ // "Registered" chip: when on, the timeline also draws creatives at their
30
+ // created_at. Default-off, so registrations are fetched lazily on toggle.
31
+ var registrationsEnabled = container.dataset.registrations === 'true';
32
+ // "Modified" chip: same idea, drawing modified creatives at their updated_at.
33
+ var modificationsEnabled = container.dataset.modifications === 'true';
34
+
27
35
  var scroll = document.createElement('div');
28
36
  scroll.className = 'timeline-scroll';
29
37
  container.appendChild(scroll);
@@ -65,6 +73,8 @@ if (!plansTimelineScriptInitialized) {
65
73
  function createPlanBar(plan, idx) {
66
74
  var el = document.createElement('div');
67
75
  el.className = 'plan-bar';
76
+ if (plan.type === 'registration') el.className += ' plan-bar--registration';
77
+ else if (plan.type === 'modification') el.className += ' plan-bar--modification';
68
78
  el.dataset.path = plan.path;
69
79
  el.dataset.id = plan.id;
70
80
  var startDateValue = plan.start_date || plan.created_at;
@@ -90,9 +100,9 @@ if (!plansTimelineScriptInitialized) {
90
100
  del.textContent = '×';
91
101
  del.className = 'delete-plan-btn';
92
102
  el.appendChild(del);
93
- del.addEventListener('click', function (e) {
103
+ del.addEventListener('click', async function (e) {
94
104
  e.stopPropagation();
95
- if (!confirm(container.dataset.deleteConfirm)) return;
105
+ if (!(await confirmDialog(container.dataset.deleteConfirm, { danger: true }))) return;
96
106
  var deleteUrl;
97
107
  if (String(plan.id).indexOf('calendar_event_') === 0) {
98
108
  deleteUrl = '/calendar_events/' + String(plan.id).replace('calendar_event_', '');
@@ -190,6 +200,7 @@ if (!plansTimelineScriptInitialized) {
190
200
  renderPlans();
191
201
  updatePlanPositions();
192
202
 
203
+ var loadSeq = 0;
193
204
  function loadPlans(centerDate) {
194
205
  var dateStr = centerDate.toISOString().slice(0, 10);
195
206
  if (container.dataset.lastLoadedDate === dateStr) return;
@@ -197,9 +208,16 @@ if (!plansTimelineScriptInitialized) {
197
208
  var listArea = document.getElementById('plans-list-area')
198
209
  var basePlansUrl = (listArea && listArea.dataset.plansUrl) || '/plans.json'
199
210
  var separator = basePlansUrl.indexOf('?') >= 0 ? '&' : '?'
200
- fetch(basePlansUrl + separator + 'date=' + dateStr)
211
+ var requestUrl = basePlansUrl + separator + 'date=' + dateStr
212
+ if (registrationsEnabled) requestUrl += '&registrations=1'
213
+ if (modificationsEnabled) requestUrl += '&modifications=1'
214
+ // Discard out-of-order responses: a slower registrations fetch must not
215
+ // overwrite the result of a later request (e.g. rapid chip on/off).
216
+ var seq = ++loadSeq;
217
+ fetch(requestUrl)
201
218
  .then(function (r) { return r.json(); })
202
219
  .then(function (newPlans) {
220
+ if (seq !== loadSeq) return;
203
221
  plans = newPlans.map(function (p) {
204
222
  if (p.start_date) {
205
223
  p.start_date = new Date(p.start_date);
@@ -235,6 +253,38 @@ if (!plansTimelineScriptInitialized) {
235
253
  todayBtn.addEventListener('click', function () { scrollToDate(new Date()); });
236
254
  }
237
255
 
256
+ // Re-fetch the currently centered window, bypassing the lastLoadedDate guard
257
+ // (used when toggling a chip changes WHAT we request for the same date).
258
+ function reloadCurrentView() {
259
+ var centerOffset = container.scrollLeft + container.clientWidth / 2;
260
+ var daysFromStart = centerOffset / dayWidth;
261
+ var centerDate = new Date(startDate.getTime() + Math.round(daysFromStart) * 86400000);
262
+ container.dataset.lastLoadedDate = '';
263
+ loadPlans(centerDate);
264
+ }
265
+
266
+ var registrationsChip = document.getElementById('chip-registrations');
267
+ if (registrationsChip) {
268
+ registrationsChip.addEventListener('click', function () {
269
+ registrationsEnabled = !registrationsEnabled;
270
+ container.dataset.registrations = registrationsEnabled ? 'true' : 'false';
271
+ registrationsChip.classList.toggle('timeline-chip--active', registrationsEnabled);
272
+ registrationsChip.setAttribute('aria-pressed', registrationsEnabled ? 'true' : 'false');
273
+ reloadCurrentView();
274
+ });
275
+ }
276
+
277
+ var modificationsChip = document.getElementById('chip-modifications');
278
+ if (modificationsChip) {
279
+ modificationsChip.addEventListener('click', function () {
280
+ modificationsEnabled = !modificationsEnabled;
281
+ container.dataset.modifications = modificationsEnabled ? 'true' : 'false';
282
+ modificationsChip.classList.toggle('timeline-chip--active', modificationsEnabled);
283
+ modificationsChip.setAttribute('aria-pressed', modificationsEnabled ? 'true' : 'false');
284
+ reloadCurrentView();
285
+ });
286
+ }
287
+
238
288
  scrollToDate(new Date());
239
289
 
240
290
  var scrollTimer;
@@ -257,7 +307,15 @@ if (!plansTimelineScriptInitialized) {
257
307
 
258
308
  // Listen for plan creation from delegated handler
259
309
  const onPlanCreated = function (e) {
260
- addPlan(e.detail);
310
+ // While a chip is active, the planned creative's derived
311
+ // registration/modification marker stops being eligible (plan anchors
312
+ // are excluded server-side), so re-fetch the authoritative view to drop
313
+ // the now-stale marker. Appending alone would leave it as a duplicate.
314
+ if (registrationsEnabled || modificationsEnabled) {
315
+ reloadCurrentView();
316
+ } else {
317
+ addPlan(e.detail);
318
+ }
261
319
  };
262
320
  document.addEventListener('plan:created', onPlanCreated);
263
321
 
@@ -398,7 +456,7 @@ if (!plansTimelineScriptInitialized) {
398
456
  if (addPlanBtn) addPlanBtn.disabled = true;
399
457
  }).catch(function (err) {
400
458
  if (err && err.errors) {
401
- alert(err.errors.join(', '));
459
+ alertDialog(err.errors.join(', '));
402
460
  } else {
403
461
  console.error(err);
404
462
  }
@@ -1,3 +1,4 @@
1
+ <%= stylesheet_link_tag "collavre_plan/timeline" %>
1
2
  <div id="plans-list-area" style="display:none;" data-plans-url="<%= collavre_plan_engine.plans_path(format: :json) %>">
2
3
  <%= render Collavre::PlansTimelineComponent.new(plans: Collavre::Plan.none) %>
3
4
  </div>
@@ -14,3 +14,7 @@ en:
14
14
  delete_confirm: Are you sure you want to delete this plan?
15
15
  today: Today
16
16
  select_creative: Select Creative
17
+ chip_registrations: Registered
18
+ chip_modifications: Modified
19
+ registration_fallback: 'Creative #%{id}'
20
+ modification_fallback: 'Creative #%{id}'
@@ -14,6 +14,10 @@ ko:
14
14
  delete_confirm: 이 계획을 삭제하시겠습니까?
15
15
  today: 오늘
16
16
  select_creative: 크리에이티브 선택
17
+ chip_registrations: 등록
18
+ chip_modifications: 수정
19
+ registration_fallback: '크리에이티브 #%{id}'
20
+ modification_fallback: '크리에이티브 #%{id}'
17
21
  activerecord:
18
22
  errors:
19
23
  models:
@@ -1,3 +1,3 @@
1
1
  module CollavrePlan
2
- VERSION = "0.1.0"
2
+ VERSION = "0.1.2"
3
3
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: collavre_plan
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.0
4
+ version: 0.1.2
5
5
  platform: ruby
6
6
  authors:
7
7
  - Collavre
@@ -45,6 +45,7 @@ extensions: []
45
45
  extra_rdoc_files: []
46
46
  files:
47
47
  - Rakefile
48
+ - app/assets/stylesheets/collavre_plan/timeline.css
48
49
  - app/components/collavre/plans_timeline_component.html.erb
49
50
  - app/components/collavre/plans_timeline_component.rb
50
51
  - app/controllers/collavre_plan/application_controller.rb