• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

Freegle / Iznik / 11495

09 May 2026 07:35AM UTC coverage: 69.06% (-3.8%) from 72.847%
11495

Pull #408

circleci

edwh
docs(migration): mark restartproject and repaircafewales as migrated (PR #408)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Pull Request #408: feat(batch): migrate check_cgas, visualise, tn_sync + dry-run improvements

9127 of 10554 branches covered (86.48%)

Branch coverage included in aggregate %.

507 of 663 new or added lines in 16 files covered. (76.47%)

11902 existing lines in 138 files now uncovered.

101630 of 149824 relevant lines covered (67.83%)

19.56 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

88.46
/iznik-nuxt3/components/MyPostsPostsList.vue
1
<template>
1✔
2
  <div class="my-posts-list">
1✔
3
    <!-- Single toolbar row: counts + old-posts toggle + search -->
1✔
4
    <div v-if="!loading && posts.length > 0" class="posts-toolbar">
1✔
5
      <span v-if="activePosts.length > 0" class="toolbar-count">
1✔
6
        <v-icon icon="gift" class="me-1" />
1✔
7
        <span class="count-full">{{ formattedActivePostsCount }}</span>
1✔
8
        <span class="count-short">{{ activePosts.length }} active</span>
1✔
9
      </span>
1✔
10
      <button
1✔
11
        v-if="oldPosts.length > 0"
1✔
12
        class="toolbar-toggle"
1✔
13
        @click="toggleShowOldPosts"
1✔
14
      >
15
        <v-icon :icon="showOldPosts ? 'eye-slash' : 'eye'" class="me-1" />
1✔
16
        <span class="count-full"
1✔
17
          >{{ showOldPosts ? 'Hide' : 'Show' }}
1✔
18
          {{ formattedOldPostsCount }}</span
1✔
19
        >
1✔
20
        <span class="count-short">{{ oldPosts.length }} old</span>
1✔
21
      </button>
1✔
22
      <div class="toolbar-search">
1✔
23
        <v-icon icon="search" class="search-icon" />
1✔
24
        <input
1✔
25
          v-model="filterText"
1✔
26
          type="text"
1✔
27
          class="search-input"
1✔
28
          placeholder="Filter posts…"
1✔
29
        />
1✔
30
        <button v-if="filterText" class="search-clear" @click="filterText = ''">
1✔
31
          <v-icon icon="times" />
1✔
32
        </button>
1✔
33
      </div>
1✔
34
    </div>
1✔
35

36
    <!-- Upcoming collections -->
1✔
37
    <div v-if="!loading && upcomingTrysts.length > 0" class="collections-card">
1✔
38
      <h3 class="collections-title">
1✔
39
        <v-icon icon="calendar-check" class="me-2" />Your upcoming collections
1✔
40
      </h3>
1✔
41
      <div
1✔
42
        v-for="group in visibleCollectionGroups"
1✔
43
        :key="group.trystdate"
1✔
44
        class="collection-group"
1✔
45
      >
46
        <div class="collection-time">
1✔
47
          <v-icon icon="calendar-alt" class="collection-icon" />
1✔
48
          {{ group.trystdate }}
1✔
49
        </div>
1✔
50
        <div
1✔
51
          v-for="item in group.items"
1✔
52
          :key="'item-' + item.id"
1✔
53
          class="collection-item"
1✔
54
        >
55
          <em>{{ item.subject }}</em
1✔
56
          ><span class="collection-who"> — {{ item.name }}</span>
1✔
57
        </div>
1✔
58
      </div>
1✔
59
      <div
1✔
60
        v-if="upcomingTrysts.length > COLLECTIONS_INITIAL"
1✔
61
        class="collections-more"
1✔
62
      >
63
        <b-button
1✔
64
          variant="link"
1✔
65
          size="sm"
1✔
66
          class="p-0"
1✔
67
          @click="showAllCollections = !showAllCollections"
1✔
68
        >
69
          {{
1✔
70
            showAllCollections
1✔
71
              ? 'Show fewer'
72
              : `Show ${upcomingTrysts.length - COLLECTIONS_INITIAL} more`
73
          }}
1✔
74
        </b-button>
1✔
75
      </div>
1✔
76
    </div>
1✔
77

78
    <!-- Loading state -->
1✔
79
    <div v-if="loading" class="loading-state">
1✔
80
      <Spinner :size="30" />
1✔
81
    </div>
1✔
82

83
    <!-- Posts list -->
1✔
84
    <div v-else-if="visiblePosts.length > 0" class="posts-container">
1✔
85
      <div v-for="post in visiblePosts" :key="'post-' + post.id">
1✔
86
        <Suspense>
1✔
87
          <MyMessage
1✔
88
            :id="post.id"
1✔
89
            :show-old="showOldPosts"
1✔
90
            :expand="defaultExpanded"
1✔
91
          />
1✔
92
          <template #fallback>
1✔
93
            <div class="loading-placeholder">
1✔
94
              <Spinner :size="40" />
1✔
95
            </div>
1✔
96
          </template>
97
        </Suspense>
1✔
98
      </div>
1✔
99
      <InfiniteLoading
1✔
100
        :key="infiniteKey"
1✔
101
        :distance="scrollboxHeight"
1✔
102
        @infinite="(event) => emit('load-more', event)"
1✔
103
      />
1✔
104
    </div>
1✔
105

106
    <!-- Empty state -->
1✔
107
    <div v-else class="empty-state">
1✔
108
      <v-icon icon="folder-open" class="empty-icon" />
1✔
109
      <p class="empty-text">You have no active posts.</p>
1✔
110
      <div class="empty-actions">
1✔
111
        <template v-if="props.type === 'Offer'">
1✔
112
          <nuxt-link to="/give" class="mobile-btn mobile-btn--give">
1✔
113
            <v-icon icon="gift" class="me-2" />Give stuff
1✔
114
          </nuxt-link>
1✔
115
        </template>
116
        <template v-else-if="props.type === 'Wanted'">
1✔
117
          <nuxt-link to="/find" class="mobile-btn mobile-btn--find">
1✔
118
            <v-icon icon="search" class="me-2" />Find stuff
1✔
119
          </nuxt-link>
1✔
120
        </template>
121
        <template v-else>
1✔
122
          <nuxt-link to="/give" class="mobile-btn mobile-btn--give">
1✔
123
            <v-icon icon="gift" class="me-2" />Give stuff
1✔
124
          </nuxt-link>
1✔
125
          <nuxt-link to="/find" class="mobile-btn mobile-btn--find">
1✔
126
            <v-icon icon="search" class="me-2" />Find stuff
1✔
127
          </nuxt-link>
1✔
128
        </template>
1✔
129
      </div>
1✔
130
    </div>
1✔
131
  </div>
1✔
132
</template>
133
<script setup>
134
import pluralize from 'pluralize'
1✔
135
import dayjs from 'dayjs'
1✔
136
import MyMessage from '~/components/MyMessage.vue'
1✔
137
import InfiniteLoading from '~/components/InfiniteLoading.vue'
1✔
138
import { useMessageStore } from '~/stores/message'
1✔
139
import { useUserStore } from '~/stores/user'
1✔
140
import { useTrystStore } from '~/stores/tryst'
1✔
141
import { useAuthStore } from '~/stores/auth'
1✔
142

143
const messageStore = useMessageStore()
1✔
144
const userStore = useUserStore()
1✔
145
const trystStore = useTrystStore()
1✔
146
const authStore = useAuthStore()
1✔
147
const myid = computed(() => authStore.user?.id)
1!
148

149
const props = defineProps({
1✔
150
  posts: { type: Array, required: true },
151
  postIds: { type: Array, required: false, default: () => [] },
152
  loading: { type: Boolean, required: true },
153
  defaultExpanded: { type: Boolean, required: true },
154
  show: { type: Number, required: true },
155
})
156

157
const emit = defineEmits(['load-more', 'toggle-old'])
1✔
158

159
const scrollboxHeight = ref(1000)
1✔
160

161
const showOldPosts = ref(false)
1✔
162
const filterText = ref('')
1✔
163
const infiniteKey = ref(0)
1✔
164

165
function toggleShowOldPosts() {
4✔
166
  showOldPosts.value = !showOldPosts.value
4✔
167
  infiniteKey.value++
4✔
168
  emit('toggle-old')
4✔
169
}
4✔
170

171
// Posts are now passed directly as props
1✔
172
const posts = computed(() => {
1✔
173
  return props.posts || []
24!
174
})
24✔
175

176
// old posts are those with an outcome
1✔
177
const oldPosts = computed(() => {
1✔
178
  return posts.value.filter((post) => post.hasoutcome)
18✔
179
})
18✔
180

181
const formattedOldPostsCount = computed(() => {
1✔
182
  return pluralize(`old post`, oldPosts.value.length, true)
6✔
183
})
6✔
184

185
const formattedActivePostsCount = computed(() => {
1✔
186
  return pluralize(`active post`, activePosts.value.length, true)
18✔
187
})
18✔
188

189
const activePosts = computed(() => {
1✔
190
  return posts.value.filter((post) => !post.hasoutcome)
24✔
191
})
24✔
192

193
watch(activePosts, (newVal) => {
1✔
UNCOV
194
  // For messages which are promised and not successful, we need to trigger a fetch.  This is so
×
UNCOV
195
  // that we can correctly show the upcoming collections.
×
UNCOV
196
  newVal.forEach((post) => {
×
UNCOV
197
    if (
×
UNCOV
198
      post.type === 'Offer' &&
×
UNCOV
199
      post.promised &&
×
UNCOV
200
      !post.hasoutcome &&
×
UNCOV
201
      !messageStore.byId(post.id)
×
UNCOV
202
    ) {
×
UNCOV
203
      messageStore.fetch(post.id)
×
UNCOV
204
    }
×
UNCOV
205
  })
×
UNCOV
206
})
×
207

208
const visiblePosts = computed(() => {
1✔
209
  let visiblePostList = showOldPosts.value ? posts.value : activePosts.value
27✔
210
  visiblePostList = visiblePostList || []
27!
211

212
  const filter = filterText.value.trim().toLowerCase()
27✔
213
  if (filter) {
27!
UNCOV
214
    visiblePostList = visiblePostList.filter((post) => {
×
215
      const msg = messageStore.byId(post.id)
×
216
      if (!msg) return true /* not loaded yet — keep visible */
×
217
      const subject = (msg.subject || '').toLowerCase()
×
UNCOV
218
      const body = (msg.textbody || msg.body || '').toLowerCase()
×
219
      return subject.includes(filter) || body.includes(filter)
×
UNCOV
220
    })
×
UNCOV
221
  }
×
222

223
  const sorted = visiblePostList.toSorted((a, b) => {
27✔
224
    /* promised items first, then by most recently */
10✔
225
    if (!showOldPosts.value && a.promised && !b.promised) {
10✔
226
      return -1
1✔
227
    } else if (!showOldPosts.value && b.promised && !a.promised) {
10!
UNCOV
228
      return 1
×
229
    } else {
9✔
230
      return new Date(b.arrival).getTime() - new Date(a.arrival).getTime()
9✔
231
    }
9✔
232
  })
27✔
233

234
  /* Active posts are few — show all immediately.
27✔
235
     Only paginate when showing old posts (can be hundreds). */
27✔
236
  return showOldPosts.value ? sorted.slice(0, props.show) : sorted
27✔
237
})
27✔
238

239
const upcomingTrysts = computed(() => {
1✔
240
  const ret = []
23✔
241

242
  activePosts.value.forEach((post) => {
23✔
243
    const message = messageStore.byId(post.id)
23✔
244
    if (post.type === 'Offer' && message?.promises?.length) {
23✔
245
      message.promises.forEach((p) => {
2✔
246
        const user = userStore?.byId(p.userid)
2✔
247
        const isSomeone = p.userid === myid.value
2✔
248

249
        if (isSomeone || user) {
2✔
250
          const tryst = trystStore?.getByUser(p.userid)
2✔
251

252
          // If tryst.arrangedfor is in the future or within the last hour
2✔
253
          if (
2✔
254
            tryst &&
2✔
255
            new Date(tryst.arrangedfor).getTime() >
2✔
256
              new Date().getTime() - 60 * 60 * 1000
2✔
257
          ) {
2✔
258
            const date = tryst
2✔
259
              ? dayjs(tryst.arrangedfor).format('dddd Do HH:mm a')
2✔
260
              : null
2!
261

262
            ret.push({
2✔
263
              id: p.userid,
2✔
264
              name: isSomeone ? 'Someone' : user.displayname,
2!
265
              tryst,
2✔
266
              trystdate: date,
2✔
267
              subject: message.subject,
2✔
268
            })
2✔
269
          }
2✔
270
        }
2✔
271
      })
2✔
272
    }
2✔
273
  })
23✔
274

275
  return ret.toSorted((a, b) => {
23✔
UNCOV
276
    return (
×
UNCOV
277
      new Date(a.tryst.arrangedfor).getTime() -
×
UNCOV
278
      new Date(b.tryst.arrangedfor).getTime()
×
UNCOV
279
    )
×
280
  })
23✔
281
})
23✔
282

283
const COLLECTIONS_INITIAL = 3
1✔
284
const showAllCollections = ref(false)
1✔
285

286
/* Group sorted trysts by time label, then optionally slice to the first
1✔
287
   COLLECTIONS_INITIAL items (across all groups) for the collapsed view. */
1✔
288
const groupedCollections = computed(() => {
1✔
289
  const groups = []
2✔
290
  const seen = {}
2✔
291
  upcomingTrysts.value.forEach((t) => {
2✔
292
    if (!seen[t.trystdate]) {
2✔
293
      seen[t.trystdate] = { trystdate: t.trystdate, items: [] }
2✔
294
      groups.push(seen[t.trystdate])
2✔
295
    }
2✔
296
    seen[t.trystdate].items.push(t)
2✔
297
  })
2✔
298
  return groups
2✔
299
})
2✔
300

301
const visibleCollectionGroups = computed(() => {
1✔
302
  if (showAllCollections.value) return groupedCollections.value
2!
303
  /* Slice to the first COLLECTIONS_INITIAL items across all groups */
2✔
304
  let remaining = COLLECTIONS_INITIAL
2✔
305
  const result = []
2✔
306
  for (const group of groupedCollections.value) {
2✔
307
    if (remaining <= 0) break
2!
308
    const items = group.items.slice(0, remaining)
2✔
309
    result.push({ trystdate: group.trystdate, items })
2✔
310
    remaining -= items.length
2✔
311
  }
2✔
312
  return result
2✔
313
})
2✔
314
</script>
315
<style scoped lang="scss">
316
@import 'assets/css/_color-vars.scss';
317

318
.my-posts-list {
319
  padding: 0;
320
}
321

322
.loading-state {
323
  display: flex;
324
  justify-content: center;
325
  align-items: center;
326
  min-height: 300px;
327
  padding: 40px;
328
  background: white;
329
  box-shadow: var(--shadow-md);
330
  opacity: 0;
331
  animation: fadeIn 0.2s ease-in forwards;
332
  animation-delay: 0.3s;
333
}
334

335
@keyframes fadeIn {
336
  to {
337
    opacity: 1;
338
  }
339
}
340

341
.posts-toolbar {
342
  display: flex;
343
  align-items: center;
344
  gap: 8px;
345
  padding: 8px 12px;
346
  margin-bottom: 12px;
347
  background: white;
348
  border: 1px solid $color-gray--light;
349
  box-shadow: var(--shadow-sm);
350
}
351

352
.toolbar-count {
353
  color: $color-success;
354
  font-weight: 500;
355
  font-size: 0.85rem;
356
  white-space: nowrap;
357
  display: inline-flex;
358
  align-items: center;
359
}
360

361
.toolbar-toggle {
362
  display: inline-flex;
363
  align-items: center;
364
  padding: 4px 10px;
365
  background: none;
366
  border: 1px solid $color-gray--light;
367
  color: var(--color-gray-600);
368
  font-size: 0.85rem;
369
  cursor: pointer;
370
  white-space: nowrap;
371
  border-radius: 4px;
372

373
  &:hover {
374
    background: $color-gray--lighter;
375
    border-color: $color-gray--base;
376
  }
377
}
378

379
/* On wider screens show full text, hide short */
380
.count-short {
381
  display: none;
382
}
383

384
@media (max-width: 480px) {
385
  .count-full {
386
    display: none;
387
  }
388
  .count-short {
389
    display: inline;
390
  }
391
  .posts-toolbar {
392
    padding: 6px 8px;
393
    gap: 6px;
394
  }
395
  .toolbar-search {
396
    min-width: 0;
397
    flex: 1;
398
  }
399
}
400

401
.toolbar-search {
402
  display: flex;
403
  align-items: center;
404
  margin-left: auto;
405
  background: $color-gray--lighter;
406
  border: 1px solid $color-gray--light;
407
  border-radius: 20px;
408
  padding: 4px 10px;
409
  gap: 6px;
410
  flex: 1;
411
  min-width: 0;
412
  max-width: 260px;
413
}
414

415
.search-icon {
416
  color: $color-gray--base;
417
  font-size: 0.8rem;
418
  flex-shrink: 0;
419
}
420

421
.search-input {
422
  border: none;
423
  background: none;
424
  outline: none;
425
  font-size: 0.85rem;
426
  width: 100%;
427
  color: $color-gray--darker;
428

429
  &::placeholder {
430
    color: $color-gray--base;
431
  }
432
}
433

434
.search-clear {
435
  background: none;
436
  border: none;
437
  padding: 0;
438
  color: $color-gray--base;
439
  cursor: pointer;
440
  font-size: 0.75rem;
441
  line-height: 1;
442
  flex-shrink: 0;
443

444
  &:hover {
445
    color: $color-gray--normal;
446
  }
447
}
448

449
.collections-card {
450
  background: white;
451
  border-radius: var(--radius-lg, 0.75rem);
452
  padding: 16px;
453
  margin-bottom: 16px;
454
  box-shadow: var(--shadow-md);
455
  border-left: 4px solid $color-blue--bright;
456
}
457

458
.collections-title {
459
  display: flex;
460
  align-items: center;
461
  font-size: 1rem;
462
  font-weight: 600;
463
  color: $color-gray--darker;
464
  margin: 0 0 12px 0;
465
}
466

467
.collection-group {
468
  margin-bottom: 8px;
469

470
  &:last-child {
471
    margin-bottom: 0;
472
  }
473
}
474

475
.collection-time {
476
  display: flex;
477
  align-items: center;
478
  gap: 6px;
479
  font-weight: 600;
480
  font-size: 0.9rem;
481
  color: $color-gray--darker;
482
  margin-bottom: 4px;
483
}
484

485
.collection-item {
486
  padding: 2px 0 2px 22px;
487
  font-size: 0.9rem;
488
  line-height: 1.4;
489
  border-bottom: none;
490
}
491

492
.collection-who {
493
  color: var(--color-gray-600);
494
}
495

496
.collections-more {
497
  margin-top: 8px;
498
  padding-top: 4px;
499
  border-top: 1px solid $color-gray--lighter;
500
}
501

502
.collection-icon {
503
  color: $color-blue--bright;
504
  font-size: 1rem;
505
  margin-top: 2px;
506
}
507

508
.posts-container {
509
  display: flex;
510
  flex-direction: column;
511
  gap: 0;
512
}
513

514
.loading-placeholder {
515
  background: white;
516
  margin-bottom: 12px;
517
  box-shadow: var(--shadow-sm);
518
}
519

520
.loading-placeholder::before {
521
  content: '';
522
  display: block;
523
  width: 100%;
524
  padding-bottom: 50%;
525
  background: $color-gray--light;
526
}
527

528
.loading-more {
529
  display: flex;
530
  justify-content: center;
531
  padding: 20px;
532
}
533

534
.empty-state {
535
  display: flex;
536
  flex-direction: column;
537
  align-items: center;
538
  justify-content: center;
539
  padding: 3rem 1.5rem;
540
  background: var(--color-gray-50);
541
  border-radius: var(--radius-lg, 0.75rem);
542
  border: 1px dashed var(--color-gray-300);
543
  max-width: 480px;
544
  margin: 1rem auto;
545
}
546

547
.empty-icon {
548
  font-size: 3rem;
549
  color: var(--color-gray-400);
550
  margin-bottom: 1rem;
551
}
552

553
.empty-text {
554
  font-size: 1.1rem;
555
  color: var(--color-gray-600);
556
  margin-bottom: 1.25rem;
557
}
558

559
.empty-actions {
560
  display: flex;
561
  gap: 12px;
562
  flex-wrap: wrap;
563
  justify-content: center;
564
}
565

566
.mobile-btn {
567
  display: flex;
568
  align-items: center;
569
  justify-content: center;
570
  padding: 0.6rem 1.5rem;
571
  font-size: 0.9rem;
572
  font-weight: 600;
573
  text-decoration: none;
574
  transition: transform 0.1s;
575

576
  &:active {
577
    transform: scale(0.98);
578
  }
579

580
  &--give {
581
    background: $color-success;
582
    color: $color-white;
583

584
    &:hover {
585
      background: darken($color-success, 5%);
586
      color: $color-white;
587
    }
588
  }
589

590
  &--find {
591
    background: $color-secondary;
592
    color: $color-white;
593

594
    &:hover {
595
      background: darken($color-secondary, 5%);
596
      color: $color-white;
597
    }
598
  }
599
}
600

601
.minheight {
602
  min-height: 200px;
603
}
604
</style>
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc