Usage Statistics and Website Demographics
Adds two new features to collect and show useful analytics. Usage Statistics: * Begin tracking daily active users who log in and interact with major features of the website each day, such as the chat room, forum and gallery. Demographics page: * For marketing, the home page now shows live statistics about the breakdown of content (explicit vs. non-explicit) on the site, and the /insights page gives a lot more data in detail. * Show the percent split in photo gallery content and how many users opt-in or share explicit content on the site. * Show high-level demographics of the members (by age range, gender, orientation) Misc cleanup: * Rearrange model list in data export to match the auto-create statements. * In data exports, include the forum_memberships, push_notifications and usage_statistics tables.
This commit is contained in:
parent
8d9588b039
commit
2f31d678d0
|
@ -83,6 +83,9 @@ const (
|
|||
|
||||
// Chat room status refresh interval.
|
||||
ChatStatusRefreshInterval = 30 * time.Second
|
||||
|
||||
// Cache TTL for the demographics page.
|
||||
DemographicsCacheTTL = time.Hour
|
||||
)
|
||||
|
||||
var (
|
||||
|
|
|
@ -154,6 +154,13 @@ func Landing() http.HandlerFunc {
|
|||
// of time where they can exist in chat but change their name on the site.
|
||||
worker.GetChatStatistics().SetOnlineNow(currentUser.Username)
|
||||
|
||||
// Ping their chat login usage statistic.
|
||||
go func() {
|
||||
if err := models.LogDailyChatUser(currentUser); err != nil {
|
||||
log.Error("LogDailyChatUser(%s): error logging this user's chat statistic: %s", currentUser.Username, err)
|
||||
}
|
||||
}()
|
||||
|
||||
// Redirect them to the chat room.
|
||||
templates.Redirect(w, strings.TrimSuffix(chatURL, "/")+"/?jwt="+ss)
|
||||
return
|
||||
|
|
|
@ -97,6 +97,13 @@ func Thread() http.HandlerFunc {
|
|||
// Is the current user subscribed to notifications on this thread?
|
||||
_, isSubscribed := models.IsSubscribed(currentUser, "threads", thread.ID)
|
||||
|
||||
// Ping this user as having used the forums today.
|
||||
go func() {
|
||||
if err := models.LogDailyForumUser(currentUser); err != nil {
|
||||
log.Error("LogDailyForumUser(%s): error logging their usage statistic: %s", currentUser.Username, err)
|
||||
}
|
||||
}()
|
||||
|
||||
var vars = map[string]interface{}{
|
||||
"Forum": forum,
|
||||
"Thread": thread,
|
||||
|
|
57
pkg/controller/index/demographics.go
Normal file
57
pkg/controller/index/demographics.go
Normal file
|
@ -0,0 +1,57 @@
|
|||
package index
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"code.nonshy.com/nonshy/website/pkg/models/demographic"
|
||||
"code.nonshy.com/nonshy/website/pkg/session"
|
||||
"code.nonshy.com/nonshy/website/pkg/templates"
|
||||
)
|
||||
|
||||
// Demographics page (/insights) to show a peek at website demographics.
|
||||
func Demographics() http.HandlerFunc {
|
||||
tmpl := templates.Must("demographics.html")
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
var (
|
||||
refresh = r.FormValue("refresh") == "true"
|
||||
)
|
||||
|
||||
// Are we refreshing? Check if an admin is logged in.
|
||||
if refresh {
|
||||
currentUser, err := session.CurrentUser(r)
|
||||
if err != nil {
|
||||
session.FlashError(w, r, "You must be logged in to do that!")
|
||||
templates.Redirect(w, r.URL.Path)
|
||||
return
|
||||
}
|
||||
|
||||
// Do the refresh?
|
||||
if currentUser.IsAdmin {
|
||||
_, err := demographic.Refresh()
|
||||
if err != nil {
|
||||
session.FlashError(w, r, "Refreshing the insights: %s", err)
|
||||
}
|
||||
}
|
||||
|
||||
templates.Redirect(w, r.URL.Path)
|
||||
return
|
||||
}
|
||||
|
||||
// Get website statistics to show on home page.
|
||||
demo, err := demographic.Get()
|
||||
if err != nil {
|
||||
session.FlashError(w, r, "Couldn't get website statistics: %s", err)
|
||||
templates.Redirect(w, "/")
|
||||
return
|
||||
}
|
||||
|
||||
vars := map[string]interface{}{
|
||||
"Demographic": demo,
|
||||
}
|
||||
|
||||
if err := tmpl.Execute(w, r, vars); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
})
|
||||
}
|
|
@ -5,6 +5,7 @@ import (
|
|||
|
||||
"code.nonshy.com/nonshy/website/pkg/config"
|
||||
"code.nonshy.com/nonshy/website/pkg/log"
|
||||
"code.nonshy.com/nonshy/website/pkg/models/demographic"
|
||||
"code.nonshy.com/nonshy/website/pkg/templates"
|
||||
)
|
||||
|
||||
|
@ -18,7 +19,17 @@ func Create() http.HandlerFunc {
|
|||
return
|
||||
}
|
||||
|
||||
if err := tmpl.Execute(w, r, nil); err != nil {
|
||||
// Get website statistics to show on home page.
|
||||
demo, err := demographic.Get()
|
||||
if err != nil {
|
||||
log.Error("demographic.Get: %s", err)
|
||||
}
|
||||
|
||||
vars := map[string]interface{}{
|
||||
"Demographic": demo,
|
||||
}
|
||||
|
||||
if err := tmpl.Execute(w, r, vars); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
|
|
@ -4,6 +4,7 @@ import (
|
|||
"net/http"
|
||||
|
||||
"code.nonshy.com/nonshy/website/pkg/config"
|
||||
"code.nonshy.com/nonshy/website/pkg/log"
|
||||
"code.nonshy.com/nonshy/website/pkg/models"
|
||||
"code.nonshy.com/nonshy/website/pkg/session"
|
||||
"code.nonshy.com/nonshy/website/pkg/templates"
|
||||
|
@ -121,6 +122,13 @@ func SiteGallery() http.HandlerFunc {
|
|||
likeMap := models.MapLikes(currentUser, "photos", photoIDs)
|
||||
commentMap := models.MapCommentCounts("photos", photoIDs)
|
||||
|
||||
// Ping this user as having used the forums today.
|
||||
go func() {
|
||||
if err := models.LogDailyGalleryUser(currentUser); err != nil {
|
||||
log.Error("LogDailyGalleryUser(%s): error logging their usage statistic: %s", currentUser.Username, err)
|
||||
}
|
||||
}()
|
||||
|
||||
var vars = map[string]interface{}{
|
||||
"IsSiteGallery": true,
|
||||
"Photos": photos,
|
||||
|
|
|
@ -48,9 +48,8 @@ func LoginRequired(handler http.Handler) http.Handler {
|
|||
// Ping LastLoginAt for long lived sessions, but not if impersonated.
|
||||
var pingLastLoginAt bool
|
||||
if time.Since(user.LastLoginAt) > config.LastLoginAtCooldown && !session.Impersonated(r) {
|
||||
user.LastLoginAt = time.Now()
|
||||
pingLastLoginAt = true
|
||||
if err := user.Save(); err != nil {
|
||||
if err := user.PingLastLoginAt(); err != nil {
|
||||
log.Error("LoginRequired: couldn't refresh LastLoginAt for user %s: %s", user.Username, err)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -59,6 +59,7 @@ func DeleteUser(user *models.User) error {
|
|||
{"IP Addresses", DeleteIPAddresses},
|
||||
{"Push Notifications", DeletePushNotifications},
|
||||
{"Forum Memberships", DeleteForumMemberships},
|
||||
{"Usage Statistics", DeleteUsageStatistics},
|
||||
}
|
||||
for _, item := range todo {
|
||||
if err := item.Fn(user.ID); err != nil {
|
||||
|
@ -406,3 +407,13 @@ func DeleteForumMemberships(userID uint64) error {
|
|||
).Delete(&models.ForumMembership{})
|
||||
return result.Error
|
||||
}
|
||||
|
||||
// DeleteUsageStatistics scrubs data for deleting a user.
|
||||
func DeleteUsageStatistics(userID uint64) error {
|
||||
log.Error("DeleteUser: DeleteUsageStatistics(%d)", userID)
|
||||
result := models.DB.Where(
|
||||
"user_id = ?",
|
||||
userID,
|
||||
).Delete(&models.UsageStatistic{})
|
||||
return result.Error
|
||||
}
|
||||
|
|
195
pkg/models/demographic/demographic.go
Normal file
195
pkg/models/demographic/demographic.go
Normal file
|
@ -0,0 +1,195 @@
|
|||
// Package demographic handles periodic report pulling for high level website statistics.
|
||||
//
|
||||
// It powers the home page and insights page, where a prospective new user can get a peek inside
|
||||
// the website to see the split between regular vs. explicit content and membership statistics.
|
||||
//
|
||||
// These database queries could get slow so the demographics are pulled and cached in this package.
|
||||
package demographic
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"sort"
|
||||
"time"
|
||||
|
||||
"code.nonshy.com/nonshy/website/pkg/config"
|
||||
)
|
||||
|
||||
// Demographic is the top level container struct with all the insights needed for front-end display.
|
||||
type Demographic struct {
|
||||
Computed bool
|
||||
LastUpdated time.Time
|
||||
Photo Photo
|
||||
People People
|
||||
}
|
||||
|
||||
// Photo statistics show the split between explicit and non-explicit content.
|
||||
type Photo struct {
|
||||
Total int64
|
||||
NonExplicit int64
|
||||
Explicit int64
|
||||
}
|
||||
|
||||
// People statistics.
|
||||
type People struct {
|
||||
Total int64
|
||||
ExplicitOptIn int64
|
||||
ExplicitPhoto int64
|
||||
ByAgeRange map[string]int64
|
||||
ByGender map[string]int64
|
||||
ByOrientation map[string]int64
|
||||
}
|
||||
|
||||
// MemberDemographic of members.
|
||||
type MemberDemographic struct {
|
||||
Label string // e.g. age range "18-25" or gender
|
||||
Count int64
|
||||
Percent int
|
||||
}
|
||||
|
||||
/**
|
||||
* Dynamic calculation methods on the above types (percentages, etc.)
|
||||
*/
|
||||
|
||||
func (d Demographic) PrettyPrint() string {
|
||||
b, err := json.MarshalIndent(d, "", "\t")
|
||||
if err != nil {
|
||||
return err.Error()
|
||||
}
|
||||
return string(b)
|
||||
}
|
||||
|
||||
func (p Photo) PercentExplicit() int {
|
||||
if p.Total == 0 {
|
||||
return 0
|
||||
}
|
||||
return int((float64(p.Explicit) / float64(p.Total)) * 100)
|
||||
}
|
||||
|
||||
func (p Photo) PercentNonExplicit() int {
|
||||
if p.Total == 0 {
|
||||
return 0
|
||||
}
|
||||
return int((float64(p.NonExplicit) / float64(p.Total)) * 100)
|
||||
}
|
||||
|
||||
func (p People) PercentExplicit() int {
|
||||
if p.Total == 0 {
|
||||
return 0
|
||||
}
|
||||
return int((float64(p.ExplicitOptIn) / float64(p.Total)) * 100)
|
||||
}
|
||||
|
||||
func (p People) PercentExplicitPhoto() int {
|
||||
if p.Total == 0 {
|
||||
return 0
|
||||
}
|
||||
return int((float64(p.ExplicitPhoto) / float64(p.Total)) * 100)
|
||||
}
|
||||
|
||||
func (p People) IterAgeRanges() []MemberDemographic {
|
||||
var (
|
||||
result = []MemberDemographic{}
|
||||
values = []string{}
|
||||
unique = map[string]struct{}{}
|
||||
)
|
||||
|
||||
for age := range p.ByAgeRange {
|
||||
if _, ok := unique[age]; !ok {
|
||||
values = append(values, age)
|
||||
}
|
||||
unique[age] = struct{}{}
|
||||
}
|
||||
|
||||
sort.Strings(values)
|
||||
for _, age := range values {
|
||||
var (
|
||||
count = p.ByAgeRange[age]
|
||||
pct int
|
||||
)
|
||||
if p.Total > 0 {
|
||||
pct = int((float64(count) / float64(p.Total)) * 100)
|
||||
}
|
||||
|
||||
result = append(result, MemberDemographic{
|
||||
Label: age,
|
||||
Count: p.ByAgeRange[age],
|
||||
Percent: pct,
|
||||
})
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
func (p People) IterGenders() []MemberDemographic {
|
||||
var (
|
||||
result = []MemberDemographic{}
|
||||
values = append(config.Gender, "")
|
||||
unique = map[string]struct{}{}
|
||||
)
|
||||
|
||||
for _, option := range values {
|
||||
unique[option] = struct{}{}
|
||||
}
|
||||
|
||||
for gender := range p.ByGender {
|
||||
if _, ok := unique[gender]; !ok {
|
||||
values = append(values, gender)
|
||||
unique[gender] = struct{}{}
|
||||
}
|
||||
}
|
||||
|
||||
for _, gender := range values {
|
||||
var (
|
||||
count = p.ByGender[gender]
|
||||
pct int
|
||||
)
|
||||
if p.Total > 0 {
|
||||
pct = int((float64(count) / float64(p.Total)) * 100)
|
||||
}
|
||||
|
||||
result = append(result, MemberDemographic{
|
||||
Label: gender,
|
||||
Count: p.ByGender[gender],
|
||||
Percent: pct,
|
||||
})
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
func (p People) IterOrientations() []MemberDemographic {
|
||||
var (
|
||||
result = []MemberDemographic{}
|
||||
values = append(config.Orientation, "")
|
||||
unique = map[string]struct{}{}
|
||||
)
|
||||
|
||||
for _, option := range values {
|
||||
unique[option] = struct{}{}
|
||||
}
|
||||
|
||||
for orientation := range p.ByOrientation {
|
||||
if _, ok := unique[orientation]; !ok {
|
||||
values = append(values, orientation)
|
||||
unique[orientation] = struct{}{}
|
||||
}
|
||||
}
|
||||
|
||||
for _, gender := range values {
|
||||
var (
|
||||
count = p.ByOrientation[gender]
|
||||
pct int
|
||||
)
|
||||
if p.Total > 0 {
|
||||
pct = int((float64(count) / float64(p.Total)) * 100)
|
||||
}
|
||||
|
||||
result = append(result, MemberDemographic{
|
||||
Label: gender,
|
||||
Count: p.ByOrientation[gender],
|
||||
Percent: pct,
|
||||
})
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
284
pkg/models/demographic/queries.go
Normal file
284
pkg/models/demographic/queries.go
Normal file
|
@ -0,0 +1,284 @@
|
|||
package demographic
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"code.nonshy.com/nonshy/website/pkg/config"
|
||||
"code.nonshy.com/nonshy/website/pkg/log"
|
||||
"code.nonshy.com/nonshy/website/pkg/models"
|
||||
)
|
||||
|
||||
// Cached statistics (in case the queries are heavy to hit too often).
|
||||
var (
|
||||
cachedDemographic Demographic
|
||||
cacheMu sync.Mutex
|
||||
)
|
||||
|
||||
// Get the current cached demographics result.
|
||||
func Get() (Demographic, error) {
|
||||
// Do we have the results cached?
|
||||
var result = cachedDemographic
|
||||
if !result.Computed || time.Since(result.LastUpdated) > config.DemographicsCacheTTL {
|
||||
cacheMu.Lock()
|
||||
defer cacheMu.Unlock()
|
||||
|
||||
// If we have a race of threads: e.g. one request is pulling the stats and the second is locked.
|
||||
// Check if we have an updated result from the first thread.
|
||||
if time.Since(cachedDemographic.LastUpdated) < config.DemographicsCacheTTL {
|
||||
return cachedDemographic, nil
|
||||
}
|
||||
|
||||
// Get the latest.
|
||||
res, err := Generate()
|
||||
if err != nil {
|
||||
return result, err
|
||||
}
|
||||
|
||||
cachedDemographic = res
|
||||
}
|
||||
|
||||
return cachedDemographic, nil
|
||||
}
|
||||
|
||||
// Refresh the demographics cache, pulling fresh results from the database every time.
|
||||
func Refresh() (Demographic, error) {
|
||||
cacheMu.Lock()
|
||||
cachedDemographic = Demographic{}
|
||||
cacheMu.Unlock()
|
||||
return Get()
|
||||
}
|
||||
|
||||
// Generate the demographics result.
|
||||
func Generate() (Demographic, error) {
|
||||
if !config.Current.Database.IsPostgres {
|
||||
return cachedDemographic, errors.New("this feature requires a PostgreSQL database")
|
||||
}
|
||||
|
||||
result := Demographic{
|
||||
Computed: true,
|
||||
LastUpdated: time.Now(),
|
||||
Photo: PhotoStatistics(),
|
||||
People: PeopleStatistics(),
|
||||
}
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// PeopleStatistics pulls various metrics about users of the website.
|
||||
func PeopleStatistics() People {
|
||||
var result = People{
|
||||
ByAgeRange: map[string]int64{},
|
||||
ByGender: map[string]int64{},
|
||||
ByOrientation: map[string]int64{},
|
||||
}
|
||||
|
||||
type record struct {
|
||||
MetricType string
|
||||
MetricValue string
|
||||
MetricCount int64
|
||||
}
|
||||
var records []record
|
||||
res := models.DB.Raw(`
|
||||
-- Users who opt in/out of explicit content
|
||||
WITH subquery_explicit AS (
|
||||
SELECT
|
||||
SUM(CASE WHEN explicit IS TRUE THEN 1 ELSE 0 END) AS explicit_count,
|
||||
SUM(CASE WHEN explicit IS NOT TRUE THEN 1 ELSE 0 END) AS non_explicit_count
|
||||
FROM users
|
||||
WHERE users.status = 'active'
|
||||
AND users.certified IS TRUE
|
||||
),
|
||||
|
||||
-- Users who share at least one explicit photo on public
|
||||
subquery_explicit_photo AS (
|
||||
SELECT
|
||||
COUNT(*) AS user_count
|
||||
FROM users
|
||||
WHERE users.status = 'active'
|
||||
AND users.certified IS TRUE
|
||||
AND EXISTS (
|
||||
SELECT 1
|
||||
FROM photos
|
||||
WHERE photos.user_id = users.id
|
||||
AND photos.explicit IS TRUE
|
||||
AND photos.visibility = 'public'
|
||||
)
|
||||
),
|
||||
|
||||
-- User counts by age
|
||||
subquery_ages AS (
|
||||
SELECT
|
||||
CASE
|
||||
WHEN DATE_PART('year', AGE(birthdate)) BETWEEN 0 AND 25 THEN '18-25'
|
||||
WHEN DATE_PART('year', AGE(birthdate)) BETWEEN 26 and 35 THEN '26-35'
|
||||
WHEN DATE_PART('year', AGE(birthdate)) BETWEEN 36 and 45 THEN '36-45'
|
||||
WHEN DATE_PART('year', AGE(birthdate)) BETWEEN 46 and 55 THEN '46-55'
|
||||
WHEN DATE_PART('year', AGE(birthdate)) BETWEEN 56 and 65 THEN '56-65'
|
||||
WHEN DATE_PART('year', AGE(birthdate)) BETWEEN 66 and 75 THEN '66-75'
|
||||
WHEN DATE_PART('year', AGE(birthdate)) BETWEEN 76 and 85 THEN '76-85'
|
||||
ELSE '86+'
|
||||
END AS age_range,
|
||||
COUNT(*) AS user_count
|
||||
FROM
|
||||
users
|
||||
GROUP BY
|
||||
CASE
|
||||
WHEN DATE_PART('year', AGE(birthdate)) BETWEEN 0 AND 25 THEN '18-25'
|
||||
WHEN DATE_PART('year', AGE(birthdate)) BETWEEN 26 and 35 THEN '26-35'
|
||||
WHEN DATE_PART('year', AGE(birthdate)) BETWEEN 36 and 45 THEN '36-45'
|
||||
WHEN DATE_PART('year', AGE(birthdate)) BETWEEN 46 and 55 THEN '46-55'
|
||||
WHEN DATE_PART('year', AGE(birthdate)) BETWEEN 56 and 65 THEN '56-65'
|
||||
WHEN DATE_PART('year', AGE(birthdate)) BETWEEN 66 and 75 THEN '66-75'
|
||||
WHEN DATE_PART('year', AGE(birthdate)) BETWEEN 76 and 85 THEN '76-85'
|
||||
ELSE '86+'
|
||||
END
|
||||
),
|
||||
|
||||
-- User counts by gender
|
||||
subquery_gender AS (
|
||||
SELECT
|
||||
profile_fields.value AS gender,
|
||||
COUNT(*) AS user_count
|
||||
FROM users
|
||||
JOIN profile_fields ON profile_fields.user_id = users.id
|
||||
WHERE users.status = 'active'
|
||||
AND users.certified IS TRUE
|
||||
AND profile_fields.name = 'gender'
|
||||
GROUP BY profile_fields.value
|
||||
),
|
||||
|
||||
-- User counts by orientation
|
||||
subquery_orientation AS (
|
||||
SELECT
|
||||
profile_fields.value AS orientation,
|
||||
COUNT(*) AS user_count
|
||||
FROM users
|
||||
JOIN profile_fields ON profile_fields.user_id = users.id
|
||||
WHERE users.status = 'active'
|
||||
AND users.certified IS TRUE
|
||||
AND profile_fields.name = 'orientation'
|
||||
GROUP BY profile_fields.value
|
||||
)
|
||||
|
||||
SELECT
|
||||
'ExplicitCount' AS metric_type,
|
||||
'explicit' AS metric_value,
|
||||
explicit_count AS metric_count
|
||||
FROM subquery_explicit
|
||||
|
||||
UNION ALL
|
||||
|
||||
SELECT
|
||||
'ExplicitPhotoCount' AS metric_type,
|
||||
'count' AS metric_value,
|
||||
user_count AS metric_count
|
||||
FROM subquery_explicit_photo
|
||||
|
||||
UNION ALL
|
||||
|
||||
SELECT
|
||||
'ExplicitCount' AS metric_type,
|
||||
'non_explicit' AS metric_value,
|
||||
non_explicit_count AS metric_count
|
||||
FROM subquery_explicit
|
||||
|
||||
UNION ALL
|
||||
|
||||
SELECT
|
||||
'AgeCounts' AS metric_type,
|
||||
age_range AS metric_value,
|
||||
user_count AS metric_count
|
||||
FROM subquery_ages
|
||||
|
||||
UNION ALL
|
||||
|
||||
SELECT
|
||||
'GenderCount' AS metric_type,
|
||||
gender AS metric_value,
|
||||
user_count AS metric_count
|
||||
FROM subquery_gender
|
||||
|
||||
UNION ALL
|
||||
|
||||
SELECT
|
||||
'OrientationCount' AS metric_type,
|
||||
orientation AS metric_value,
|
||||
user_count AS metric_count
|
||||
FROM subquery_orientation
|
||||
`).Scan(&records)
|
||||
if res.Error != nil {
|
||||
log.Error("PeopleStatistics: %s", res.Error)
|
||||
return result
|
||||
}
|
||||
|
||||
// Ingest the records.
|
||||
for _, row := range records {
|
||||
switch row.MetricType {
|
||||
case "ExplicitCount":
|
||||
result.Total += row.MetricCount
|
||||
if row.MetricValue == "explicit" {
|
||||
result.ExplicitOptIn = row.MetricCount
|
||||
}
|
||||
case "ExplicitPhotoCount":
|
||||
result.ExplicitPhoto = row.MetricCount
|
||||
case "AgeCounts":
|
||||
if _, ok := result.ByAgeRange[row.MetricValue]; !ok {
|
||||
result.ByAgeRange[row.MetricValue] = 0
|
||||
}
|
||||
result.ByAgeRange[row.MetricValue] += row.MetricCount
|
||||
case "GenderCount":
|
||||
if _, ok := result.ByGender[row.MetricValue]; !ok {
|
||||
result.ByGender[row.MetricValue] = 0
|
||||
}
|
||||
result.ByGender[row.MetricValue] += row.MetricCount
|
||||
case "OrientationCount":
|
||||
if _, ok := result.ByOrientation[row.MetricValue]; !ok {
|
||||
result.ByOrientation[row.MetricValue] = 0
|
||||
}
|
||||
result.ByOrientation[row.MetricValue] += row.MetricCount
|
||||
}
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
// PhotoStatistics gets info about photo usage on the website.
|
||||
//
|
||||
// Counts of Explicit vs. Non-Explicit photos.
|
||||
func PhotoStatistics() Photo {
|
||||
var result Photo
|
||||
type record struct {
|
||||
Explicit bool
|
||||
C int64
|
||||
}
|
||||
var records []record
|
||||
|
||||
res := models.DB.Raw(`
|
||||
SELECT
|
||||
photos.explicit,
|
||||
count(photos.id) AS c
|
||||
FROM
|
||||
photos
|
||||
WHERE
|
||||
photos.visibility = 'public'
|
||||
GROUP BY photos.explicit
|
||||
ORDER BY c DESC
|
||||
`).Scan(&records)
|
||||
if res.Error != nil {
|
||||
log.Error("PhotoStatistics: %s", res.Error)
|
||||
return result
|
||||
}
|
||||
|
||||
for _, row := range records {
|
||||
result.Total += row.C
|
||||
if row.Explicit {
|
||||
result.Explicit += row.C
|
||||
} else {
|
||||
result.NonExplicit += row.C
|
||||
}
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
|
@ -19,30 +19,33 @@ func ExportModels(zw *zip.Writer, user *models.User) error {
|
|||
// List of tables to export. Keep the ordering in sync with
|
||||
// the AutoMigrate() calls in ../models.go
|
||||
var todo = []task{
|
||||
{"User", ExportUserTable},
|
||||
// Note: AdminGroup info is eager-loaded in User export
|
||||
{"Block", ExportBlockTable},
|
||||
{"CertificationPhoto", ExportCertificationPhotoTable},
|
||||
{"ChangeLog", ExportChangeLogTable},
|
||||
{"Comment", ExportCommentTable},
|
||||
{"CommentPhoto", ExportCommentPhotoTable},
|
||||
{"Feedback", ExportFeedbackTable},
|
||||
{"ForumMembership", ExportForumMembershipTable},
|
||||
{"Friend", ExportFriendTable},
|
||||
{"Forum", ExportForumTable},
|
||||
{"IPAddress", ExportIPAddressTable},
|
||||
{"Like", ExportLikeTable},
|
||||
{"Message", ExportMessageTable},
|
||||
{"Notification", ExportNotificationTable},
|
||||
{"ProfileField", ExportProfileFieldTable},
|
||||
{"Photo", ExportPhotoTable},
|
||||
{"PrivatePhoto", ExportPrivatePhotoTable},
|
||||
{"CertificationPhoto", ExportCertificationPhotoTable},
|
||||
{"Message", ExportMessageTable},
|
||||
{"Friend", ExportFriendTable},
|
||||
{"Block", ExportBlockTable},
|
||||
{"Feedback", ExportFeedbackTable},
|
||||
{"Forum", ExportForumTable},
|
||||
{"Thread", ExportThreadTable},
|
||||
{"Comment", ExportCommentTable},
|
||||
{"Like", ExportLikeTable},
|
||||
{"Notification", ExportNotificationTable},
|
||||
{"Subscription", ExportSubscriptionTable},
|
||||
{"CommentPhoto", ExportCommentPhotoTable},
|
||||
// Note: Poll table is eager-loaded in Thread export
|
||||
{"PollVote", ExportPollVoteTable},
|
||||
// Note: AdminGroup info is eager-loaded in User export
|
||||
{"PrivatePhoto", ExportPrivatePhotoTable},
|
||||
{"PushNotification", ExportPushNotificationTable},
|
||||
{"Subscription", ExportSubscriptionTable},
|
||||
{"Thread", ExportThreadTable},
|
||||
{"TwoFactor", ExportTwoFactorTable},
|
||||
{"UsageStatistic", ExportUsageStatisticTable},
|
||||
{"User", ExportUserTable},
|
||||
{"UserLocation", ExportUserLocationTable},
|
||||
{"UserNote", ExportUserNoteTable},
|
||||
{"ChangeLog", ExportChangeLogTable},
|
||||
{"TwoFactor", ExportTwoFactorTable},
|
||||
{"IPAddress", ExportIPAddressTable},
|
||||
}
|
||||
for _, item := range todo {
|
||||
log.Info("Exporting data model: %s", item.Step)
|
||||
|
@ -444,3 +447,48 @@ func ExportIPAddressTable(zw *zip.Writer, user *models.User) error {
|
|||
|
||||
return ZipJson(zw, "ip_addresses.json", items)
|
||||
}
|
||||
|
||||
func ExportForumMembershipTable(zw *zip.Writer, user *models.User) error {
|
||||
var (
|
||||
items = []*models.ForumMembership{}
|
||||
query = models.DB.Model(&models.ForumMembership{}).Where(
|
||||
"user_id = ?",
|
||||
user.ID,
|
||||
).Find(&items)
|
||||
)
|
||||
if query.Error != nil {
|
||||
return query.Error
|
||||
}
|
||||
|
||||
return ZipJson(zw, "forum_memberships.json", items)
|
||||
}
|
||||
|
||||
func ExportPushNotificationTable(zw *zip.Writer, user *models.User) error {
|
||||
var (
|
||||
items = []*models.PushNotification{}
|
||||
query = models.DB.Model(&models.PushNotification{}).Where(
|
||||
"user_id = ?",
|
||||
user.ID,
|
||||
).Find(&items)
|
||||
)
|
||||
if query.Error != nil {
|
||||
return query.Error
|
||||
}
|
||||
|
||||
return ZipJson(zw, "push_notifications.json", items)
|
||||
}
|
||||
|
||||
func ExportUsageStatisticTable(zw *zip.Writer, user *models.User) error {
|
||||
var (
|
||||
items = []*models.UsageStatistic{}
|
||||
query = models.DB.Model(&models.UsageStatistic{}).Where(
|
||||
"user_id = ?",
|
||||
user.ID,
|
||||
).Find(&items)
|
||||
)
|
||||
if query.Error != nil {
|
||||
return query.Error
|
||||
}
|
||||
|
||||
return ZipJson(zw, "usage_statistics.json", items)
|
||||
}
|
||||
|
|
|
@ -31,9 +31,10 @@ func AutoMigrate() {
|
|||
&Poll{}, // vacuum script cleans up orphaned polls
|
||||
&PrivatePhoto{}, // ✔
|
||||
&PushNotification{}, // ✔
|
||||
&Subscription{}, // ✔
|
||||
&Thread{}, // ✔
|
||||
&TwoFactor{}, // ✔
|
||||
&Subscription{}, // ✔
|
||||
&UsageStatistic{}, // ✔
|
||||
&User{}, // ✔
|
||||
&UserLocation{}, // ✔
|
||||
&UserNote{}, // ✔
|
||||
|
|
105
pkg/models/usage_statistic.go
Normal file
105
pkg/models/usage_statistic.go
Normal file
|
@ -0,0 +1,105 @@
|
|||
package models
|
||||
|
||||
import "time"
|
||||
|
||||
/*
|
||||
UsageStatistic holds basic analytics points for things like daily/monthly active user counts.
|
||||
|
||||
Generally, there will be one UserStatistic row for each combination of a UserID and Type for
|
||||
each calendar day of the year. Type names may be like "dau" to log daily logins (Daily Active User),
|
||||
or "chat" to log daily chat room users.
|
||||
|
||||
If a user logs in multiple times in the same day, their existing UsageStatistic for that day
|
||||
is reused and the Counter is incremented. So if a user joins chat 3 times on the same day, there
|
||||
will be a single row for that date for that user, but with a Counter of 3 in that case.
|
||||
|
||||
This makes it easier to query for aggregate reports on daily/monthly active users since each
|
||||
row/event type combo only appears once per user per day.
|
||||
*/
|
||||
type UsageStatistic struct {
|
||||
ID uint64 `gorm:"primaryKey"`
|
||||
UserID uint64 `gorm:"uniqueIndex:idx_usage_statistics"`
|
||||
Type string `gorm:"uniqueIndex:idx_usage_statistics"`
|
||||
Date string `gorm:"uniqueIndex:idx_usage_statistics"` // unique days, yyyy-mm-dd format.
|
||||
Counter uint64
|
||||
CreatedAt time.Time `gorm:"index"` // full timestamps
|
||||
UpdatedAt time.Time `gorm:"index"`
|
||||
}
|
||||
|
||||
// Options for UsageStatistic Type values.
|
||||
const (
|
||||
UsageStatisticDailyVisit = "dau" // daily active user counter
|
||||
UsageStatisticChatEntry = "chat" // daily chat room users
|
||||
UsageStatisticForumUser = "forum" // daily forum users (when they open a thread)
|
||||
UsageStatisticGalleryUser = "gallery" // daily Site Gallery user (when viewing the site gallery)
|
||||
)
|
||||
|
||||
// LogDailyActiveUser will ping a UserStatistic for the current user to mark them present for the day.
|
||||
func LogDailyActiveUser(user *User) error {
|
||||
var (
|
||||
date = time.Now().Format(time.DateOnly)
|
||||
_, err = IncrementUsageStatistic(user, UsageStatisticDailyVisit, date)
|
||||
)
|
||||
return err
|
||||
}
|
||||
|
||||
// LogDailyChatUser will ping a UserStatistic for the current user to mark them as having used the chat room today.
|
||||
func LogDailyChatUser(user *User) error {
|
||||
var (
|
||||
date = time.Now().Format(time.DateOnly)
|
||||
_, err = IncrementUsageStatistic(user, UsageStatisticChatEntry, date)
|
||||
)
|
||||
return err
|
||||
}
|
||||
|
||||
// LogDailyForumUser will ping a UserStatistic for the current user to mark them as having used the forums today.
|
||||
func LogDailyForumUser(user *User) error {
|
||||
var (
|
||||
date = time.Now().Format(time.DateOnly)
|
||||
_, err = IncrementUsageStatistic(user, UsageStatisticForumUser, date)
|
||||
)
|
||||
return err
|
||||
}
|
||||
|
||||
// LogDailyGalleryUser will ping a UserStatistic for the current user to mark them as having used the site gallery today.
|
||||
func LogDailyGalleryUser(user *User) error {
|
||||
var (
|
||||
date = time.Now().Format(time.DateOnly)
|
||||
_, err = IncrementUsageStatistic(user, UsageStatisticGalleryUser, date)
|
||||
)
|
||||
return err
|
||||
}
|
||||
|
||||
// GetUsageStatistic looks up a user statistic.
|
||||
func GetUsageStatistic(user *User, statType, date string) (*UsageStatistic, error) {
|
||||
var (
|
||||
result = &UsageStatistic{}
|
||||
res = DB.Model(&UsageStatistic{}).Where(
|
||||
"user_id = ? AND type = ? AND date = ?",
|
||||
user.ID, statType, date,
|
||||
).First(&result)
|
||||
)
|
||||
return result, res.Error
|
||||
}
|
||||
|
||||
// IncrementUsageStatistic finds or creates a UserStatistic type and increments the counter.
|
||||
func IncrementUsageStatistic(user *User, statType, date string) (*UsageStatistic, error) {
|
||||
user.muStatistic.Lock()
|
||||
defer user.muStatistic.Unlock()
|
||||
|
||||
// Is there an existing row?
|
||||
stat, err := GetUsageStatistic(user, statType, date)
|
||||
if err != nil {
|
||||
stat = &UsageStatistic{
|
||||
UserID: user.ID,
|
||||
Type: statType,
|
||||
Counter: 0,
|
||||
Date: date,
|
||||
}
|
||||
}
|
||||
|
||||
// Update and save it.
|
||||
stat.Counter++
|
||||
err = DB.Save(stat).Error
|
||||
return stat, err
|
||||
}
|
|
@ -6,6 +6,7 @@ import (
|
|||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"code.nonshy.com/nonshy/website/pkg/config"
|
||||
|
@ -45,6 +46,9 @@ type User struct {
|
|||
cachePhotoTypes map[PhotoVisibility]struct{}
|
||||
cacheBlockedUserIDs []uint64
|
||||
cachePhotoIDs []uint64
|
||||
|
||||
// Feature mutexes.
|
||||
muStatistic sync.Mutex
|
||||
}
|
||||
|
||||
type UserVisibility string
|
||||
|
@ -226,6 +230,17 @@ func IsValidUsername(username string) error {
|
|||
return nil
|
||||
}
|
||||
|
||||
// PingLastLoginAt refreshes the user's "last logged in" time.
|
||||
func (u *User) PingLastLoginAt() error {
|
||||
// Also ping their daily active user statistic.
|
||||
if err := LogDailyActiveUser(u); err != nil {
|
||||
log.Error("PingLastLoginAt(%s): couldn't log daily active user statistic: %s", u.Username, err)
|
||||
}
|
||||
|
||||
u.LastLoginAt = time.Now()
|
||||
return u.Save()
|
||||
}
|
||||
|
||||
// IsBanned returns if the user account is banned.
|
||||
func (u *User) IsBanned() bool {
|
||||
return u.Status == UserStatusBanned
|
||||
|
|
|
@ -34,6 +34,7 @@ func New() http.Handler {
|
|||
mux.HandleFunc("GET /sw.js", index.ServiceWorker())
|
||||
mux.HandleFunc("GET /about", index.StaticTemplate("about.html")())
|
||||
mux.HandleFunc("GET /features", index.StaticTemplate("features.html")())
|
||||
mux.HandleFunc("GET /insights", index.Demographics())
|
||||
mux.HandleFunc("GET /faq", index.StaticTemplate("faq.html")())
|
||||
mux.HandleFunc("GET /tos", index.StaticTemplate("tos.html")())
|
||||
mux.HandleFunc("GET /privacy", index.StaticTemplate("privacy.html")())
|
||||
|
|
|
@ -172,8 +172,7 @@ func LoginUser(w http.ResponseWriter, r *http.Request, u *models.User) error {
|
|||
sess.Save(w)
|
||||
|
||||
// Ping the user's last login time.
|
||||
u.LastLoginAt = time.Now()
|
||||
return u.Save()
|
||||
return u.PingLastLoginAt()
|
||||
}
|
||||
|
||||
// ImpersonateUser assumes the role of the user impersonated by an admin uid.
|
||||
|
|
233
web/templates/demographics.html
Normal file
233
web/templates/demographics.html
Normal file
|
@ -0,0 +1,233 @@
|
|||
{{define "title"}}A peek inside the {{.Title}} website{{end}}
|
||||
{{define "content"}}
|
||||
<div class="block">
|
||||
<section class="hero is-light is-bold">
|
||||
<div class="hero-body">
|
||||
<div class="container">
|
||||
<h1 class="title">A peek inside the {{PrettyTitle}} website</h1>
|
||||
<h2 class="subtitle">Some statistics & demographics of our members & content</h2>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<div class="block p-4">
|
||||
<div class="content">
|
||||
<p>
|
||||
This page provides some insights into the distribution of content and member demographics
|
||||
in the {{PrettyTitle}} community.
|
||||
</p>
|
||||
|
||||
<p>
|
||||
If you are a prospective new member and are curious about whether this isn't actually "just another porn site,"
|
||||
hopefully this page will help answer some of those questions for you.
|
||||
</p>
|
||||
|
||||
<p>
|
||||
We have found that {{PrettyTitle}} actually fills a much needed niche <em>in between</em> the two
|
||||
opposite poles of the "strict naturist website" and the "hyper sexual porn sites" that exist elsewhere
|
||||
online. Many of our members have come here from other major nudist websites, and we have so far maintained a
|
||||
nice balance in content: <strong>most</strong> of what people share here are "normal nudes" that
|
||||
don't contain any sexual undertones at all!
|
||||
</p>
|
||||
|
||||
<p>
|
||||
On our <a href="/features#webcam-chat-room">webcam chat room</a>, though we permit people to be sexual
|
||||
on camera when they want to be, we typically maintain a balance where <em>most</em> of the webcams are
|
||||
"blue" (or non-sexual in nature). Members are asked to mark their webcams as 'explicit' (red) when they are
|
||||
being horny, so you may have informed consent as to whether you want to open their red cam and watch.
|
||||
</p>
|
||||
|
||||
<p>
|
||||
The insights on this page should hopefully back up this story with some hard numbers so you may get a preview
|
||||
of what you may expect to find on this website should you decide to join us here.
|
||||
</p>
|
||||
|
||||
<p>
|
||||
{{PrettyTitle}} is open to <strong>all</strong> nudists & exhibitionists and we'd love to have you join our community!
|
||||
</p>
|
||||
|
||||
<p>
|
||||
<strong><em>Last Updated:</em></strong> these website statistics were last collected
|
||||
on {{.Demographic.LastUpdated.Format "Jan _2, 2006 @ 15:04:05 MST"}}
|
||||
|
||||
<!-- Admin: force refresh -->
|
||||
{{if .CurrentUser.IsAdmin}}
|
||||
<a href="{{.Request.URL.Path}}?refresh=true" class="has-text-danger ml-2">
|
||||
<i class="fa fa-peace"></i> Refresh now
|
||||
</a>
|
||||
{{end}}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!--
|
||||
Photo Gallery Statistics
|
||||
-->
|
||||
|
||||
<hr>
|
||||
|
||||
<div class="content">
|
||||
<h2>
|
||||
<i class="fa fa-image mr-2"></i>
|
||||
Photo Gallery
|
||||
</h2>
|
||||
|
||||
<p>
|
||||
Our members have shared <strong>{{FormatNumberCommas .Demographic.Photo.Total}}</strong> photos on
|
||||
"public" for the whole {{PrettyTitle}} community to see. The majority of these photos tend to be "normal nudes"
|
||||
and are non-sexual in nature (for example, not featuring so much as an erection or sexually enticing pose).
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="columns">
|
||||
<div class="column is-2">
|
||||
<strong>Non-Explicit</strong>
|
||||
<small class="has-text-grey">({{FormatNumberCommas .Demographic.Photo.NonExplicit}})</small>
|
||||
</div>
|
||||
<div class="column pt-4">
|
||||
<progress class="progress is-link has-background-dark"
|
||||
value="{{.Demographic.Photo.PercentNonExplicit}}"
|
||||
max="100"
|
||||
title="{{.Demographic.Photo.PercentNonExplicit}}%">
|
||||
{{.Demographic.Photo.PercentNonExplicit}}%
|
||||
</progress>
|
||||
</div>
|
||||
<div class="column is-narrow">
|
||||
{{.Demographic.Photo.PercentNonExplicit}}%
|
||||
</div>
|
||||
</div>
|
||||
<div class="columns">
|
||||
<div class="column is-2">
|
||||
<strong>Explicit</strong>
|
||||
<small class="has-text-grey">({{FormatNumberCommas .Demographic.Photo.Explicit}})</small>
|
||||
</div>
|
||||
<div class="column pt-4">
|
||||
<progress class="progress is-danger has-background-dark"
|
||||
value="{{.Demographic.Photo.PercentExplicit}}"
|
||||
max="100"
|
||||
title="{{.Demographic.Photo.PercentExplicit}}%">
|
||||
{{.Demographic.Photo.PercentExplicit}}%
|
||||
</progress>
|
||||
</div>
|
||||
<div class="column is-narrow">
|
||||
{{.Demographic.Photo.PercentExplicit}}%
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="content">
|
||||
<p>
|
||||
Remember: this website is "nudist friendly" by default, and you must <strong>opt-in</strong> if you want to
|
||||
see the 'explicit' content on this website.
|
||||
</p>
|
||||
|
||||
<p>
|
||||
So far: <strong>{{FormatNumberCommas .Demographic.People.ExplicitOptIn}}</strong> of our members
|
||||
({{.Demographic.People.PercentExplicit}}%) opt-in to see explicit content, and
|
||||
<strong>{{FormatNumberCommas .Demographic.People.ExplicitPhoto}}</strong>
|
||||
({{.Demographic.People.PercentExplicitPhoto}}%) of them have actually shared at least one 'explicit'
|
||||
photo on their public gallery.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!--
|
||||
People statistics
|
||||
-->
|
||||
|
||||
<hr>
|
||||
|
||||
<div class="content">
|
||||
<h2>
|
||||
<i class="fa fa-people-group mr-2"></i>
|
||||
People
|
||||
</h2>
|
||||
|
||||
<p>
|
||||
We currently have <strong>{{FormatNumberCommas .Demographic.People.Total}}</strong> members who have
|
||||
verified their <a href="/faq#certification-faqs">certification photo</a> with the site admin.
|
||||
</p>
|
||||
|
||||
<p>
|
||||
Below are some high-level demographics of who you may expect to find on this website. As a note on
|
||||
genders: the majority of our members tend to be men, but we do have a handful of women here too.
|
||||
{{PrettyTitle}} grows <em>exclusively</em> by word of mouth: if you know any women who might like
|
||||
the vibe of this place, please do recommend that they check it out!
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="columns">
|
||||
<div class="column">
|
||||
<h4 class="is-size-5 mb-4">By Age Range</h4>
|
||||
|
||||
{{range .Demographic.People.IterAgeRanges}}
|
||||
<div class="columns">
|
||||
<div class="column is-3">
|
||||
<strong>{{or .Label "(No answer)"}}</strong>
|
||||
<small class="has-text-grey">({{FormatNumberCommas .Count}})</small>
|
||||
</div>
|
||||
<div class="column pt-4">
|
||||
<progress class="progress is-success has-background-dark"
|
||||
value="{{.Percent}}"
|
||||
max="100"
|
||||
title="{{.Percent}}%">
|
||||
{{.Percent}}%
|
||||
</progress>
|
||||
</div>
|
||||
<div class="column is-narrow">
|
||||
{{.Percent}}%
|
||||
</div>
|
||||
</div>
|
||||
{{end}}
|
||||
</div>
|
||||
|
||||
<div class="column">
|
||||
<h4 class="is-size-5 mb-4">By Gender</h4>
|
||||
|
||||
{{range .Demographic.People.IterGenders}}
|
||||
<div class="columns">
|
||||
<div class="column is-3">
|
||||
<strong>{{or .Label "No answer"}}</strong>
|
||||
<small class="has-text-grey">({{FormatNumberCommas .Count}})</small>
|
||||
</div>
|
||||
<div class="column pt-4">
|
||||
<progress class="progress is-link has-background-dark"
|
||||
value="{{.Percent}}"
|
||||
max="100"
|
||||
title="{{.Percent}}%">
|
||||
{{.Percent}}%
|
||||
</progress>
|
||||
</div>
|
||||
<div class="column is-narrow">
|
||||
{{.Percent}}%
|
||||
</div>
|
||||
</div>
|
||||
{{end}}
|
||||
</div>
|
||||
</div>
|
||||
<div class="columns">
|
||||
<div class="column is-half">
|
||||
<h4 class="is-size-5 mb-4">By Orientation</h4>
|
||||
|
||||
{{range .Demographic.People.IterOrientations}}
|
||||
<div class="columns">
|
||||
<div class="column is-3">
|
||||
<strong>{{or .Label "(No answer)"}}</strong>
|
||||
<small class="has-text-grey">({{FormatNumberCommas .Count}})</small>
|
||||
</div>
|
||||
<div class="column pt-4">
|
||||
<progress class="progress is-danger has-background-dark"
|
||||
value="{{.Percent}}"
|
||||
max="100"
|
||||
title="{{.Percent}}%">
|
||||
{{.Percent}}%
|
||||
</progress>
|
||||
</div>
|
||||
<div class="column is-narrow">
|
||||
{{.Percent}}%
|
||||
</div>
|
||||
</div>
|
||||
{{end}}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
{{end}}
|
|
@ -41,29 +41,34 @@
|
|||
content is strictly opt-in and the default is to hide any explicit photos or forums from your view.
|
||||
</p>
|
||||
|
||||
<!-- Show a peek at website demographics -->
|
||||
{{if .Demographic.Computed}}
|
||||
<h4><em>A peek inside the site:</em></h4>
|
||||
|
||||
<p>
|
||||
{{PrettyTitle}} has been found to fill a much needed niche in between the "strict naturist websites"
|
||||
and the "hyper sexual porn sites" out there. As of <strong>July 31, 2024</strong> here is a brief peek inside the
|
||||
website to see what the balance of content is like in our community.
|
||||
and the "hyper sexual porn sites" out there. As of <strong>{{.Demographic.LastUpdated.Format "January _2, 2006"}}</strong> here is a brief
|
||||
<a href="/insights">peek inside the website</a> to see what the balance of content is like in our community.
|
||||
</p>
|
||||
|
||||
<ul>
|
||||
<li>
|
||||
Photo Gallery: only 24% of our photos are 'explicit' or sexual in nature (6,750 out of 27,331).
|
||||
Photo Gallery: only {{.Demographic.Photo.PercentExplicit}}% of our photos are 'explicit' or sexual in nature ({{FormatNumberCommas .Demographic.Photo.Explicit}} out of {{FormatNumberCommas .Demographic.Photo.Total}}).
|
||||
It is strictly opt-in if you want to see that stuff - it's hidden by default!
|
||||
</li>
|
||||
<li>Nudists vs. Exhibitionists: 3,209 (71%, out of 4,462) of members have opted-in to see explicit content on the site.
|
||||
Only 45% of members (2,022) have shared at least one 'explicit' photo on their gallery.</li>
|
||||
<li>Nudists vs. Exhibitionists: {{FormatNumberCommas .Demographic.People.ExplicitOptIn}} ({{.Demographic.People.PercentExplicit}}%, out of {{FormatNumberCommas .Demographic.People.Total}}) of members have opted-in to see explicit content on the site.
|
||||
Only {{.Demographic.People.PercentExplicitPhoto}}% of members ({{FormatNumberCommas .Demographic.People.ExplicitPhoto}}) have shared at least one 'explicit' photo on their gallery.</li>
|
||||
</ul>
|
||||
|
||||
<p>
|
||||
<strong>
|
||||
<i class="fa fa-circle-arrow-right mr-1"></i> See more:
|
||||
</strong>
|
||||
<small>
|
||||
(Coming soon: a 'live statistics' page which will give up-to-date information and pretty graphs & charts;
|
||||
in the mean time these were manually gathered).
|
||||
<a href="/insights">Click here to see detailed insights</a> about the people and content in our community -- updated regularly!
|
||||
</small>
|
||||
</p>
|
||||
{{end}}
|
||||
|
||||
</div>
|
||||
|
||||
|
|
Loading…
Reference in New Issue
Block a user